diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 276801603..fdff245b7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,3 +1,127 @@ +# Operating Rules — READ THIS FIRST + +These rules are not style preferences. Each one is written down because ignoring it +already cost this project a multi-day outage across seven runtimes. The rest of this +file describes *what* to build; this section governs *how* to work. When the two +conflict, this section wins. + +## 1. Vectors are the only authority + +The files under `spec/vectors/**` define correctness. Nothing else does. + +- **Before asserting any constraint, open the vector and quote it.** If you cannot cite + a specific vector file and the specific assertion in it, you do not have a + requirement — you have an opinion, and you must label it as one. +- **Do not inherit constraints from another session, a PR description, or a chat + summary.** Re-derive them from the vector yourself. A false constraint propagates + silently and is nearly impossible to kill once several sessions repeat it. +- **A vector that only checks `load → save → reload` equality does not require anything + about which type the payload loads as.** Read what is asserted, not what you assume is + implied. + +> This exact failure: a "must deserialize as the base type" constraint circulated for a +> full day and drove an emitter-release escalation. It appears in no vector. The real +> requirement was byte-exact payload roundtrip, satisfied by a two-line schema edit. + +## 2. Establish a green baseline before touching a generated-code dependency + +Bumping `@typra/emitter` (or any codegen dependency) regenerates thousands of files +across every runtime at once. Without a baseline you cannot tell a regression you just +introduced from a failure that was already there. + +Required sequence, in order: + +1. `git stash` everything. Run the full suite on the untouched pin. **Record the exact + pass/fail counts.** If it is not green, stop and report that first. +2. Bump the dependency alone, in its own commit, and regenerate. +3. Re-run and diff against the recorded baseline. +4. Attribute every new failure before writing any fix. + +Never bump a codegen dependency in the same commit as a schema change or a handwritten +fix. Keep schema edits, regeneration, and handwritten-seam repairs in **separate +commits**, always in that order, so each can be reviewed and reverted independently. + +## 3. Try the cheap experiment before escalating + +Escalation — asking for a publish, a release, an approval, or another team's change — +is the **last** resort, not the first. + +Before escalating anything, you must have run the direct experiment. Concretely: if a +question is "which schema shape satisfies this vector?", the answer is 10 minutes of +editing the `.tsp` and running the test, times the two or three candidate shapes. Do +that, report a measured comparison, and the argument ends. + +**Hard rule: never let a thread run on whether a version exists, is publishable, or is +authorized.** That is an availability argument, and it cannot resolve a correctness +question. If you notice a thread arguing about versions, stop and go measure instead. + +> This exact failure: ~24 hours were spent arguing about which `@typra/emitter` version +> could be published. No publish was ever needed. Three candidate schema shapes were +> A/B'd afterward in under an hour and settled it outright. + +## 4. Report measurements, never impressions + +Every status claim must carry a number and a command that produces it. + +- Good: `cargo test -p prompty` → `293 passed / 3 failed`; failures at + `tool.rs:115`, uninvestigated. +- Unacceptable: "mostly working", "should be fine now", "parity achieved". + +**State explicitly what you did not verify.** An unqualified success report that hides +six untested runtimes is worse than no report, because it gets built upon. If you tested +one runtime, say that one runtime was tested and name the six that were not. + +## 5. Orchestration has a hard budget + +One session per branch per PR. Beyond that: + +- **Give every child session a falsifiable definition of done** — a command and an + expected result, not a narrative goal. +- **Two exchanges on the same blocker is the cap.** If a blocker survives two rounds of + messaging, stop delegating and reproduce it yourself in one worktree. Coordination + cost grows with the number of sessions; debugging cost does not. +- **Never let sessions negotiate with each other about a shared dependency.** Route it + to a single owner — the coordinator — who decides by measurement. +- **`archive_session` only works on sessions you created.** "Idle" is not "stopped": an + idle session will resume on its next message. If you need work halted and cannot + archive a session, say so plainly and name the sessions the user must stop. + +## 6. When told to stop, commit and preserve + +On "stop", the work is not finished — it is *preserved*. Do this before reporting: + +1. Commit every dirty worktree to a `wip/` branch. Never leave uncommitted work. +2. Split into reviewable commits so the user can cherry-pick the good parts out of the + bad ones. +3. In each commit message, state what is verified, what is not, and what is actively + known to be broken. +4. **Flag any state that is not reproducible from the manifest** — a `--no-save` + install, a locally built package, a hand-edited `node_modules`. Someone will run + `npm ci` and silently get different bytes. +5. Delete stale artifacts from abandoned experiments rather than committing them. + +## 7. Repo-specific facts that waste time when rediscovered + +- **Push with the `ssh` remote.** `origin` (`https://github.com/microsoft/prompty`) + fails with a SAML SSO 403. +- **`origin/*` tracking refs go stale**, so "N unpushed commits" and `@{u}` comparisons + routinely lie. Verify against the `ssh` remote or `gh pr view` before concluding work + was lost. +- **npm versions are immutable.** A version already published can never be republished + with different bytes. Any fix requires a strictly higher version number. +- **`Connection` is an open discriminator**: bare `kind: string` on an `@abstract` base, + with `@discriminator("kind")`. **Do not add a `kind: "*"` wildcard subtype** — a + wildcard serializes only declared fields and structurally drops the arbitrary + top-level keys the vectors require. This is the one place the `Tool` / `CustomTool` + pattern must *not* be copied. +- **A non-abstract polymorphic base must absorb discriminator values no subtype claims.** + `Property` is concrete and its union permits `string`, `integer`, `float`, `boolean`, + `thread`, `audio` — none of which have subtypes. They must load as the base, not + panic. Emitter ≥ 0.4.15 regressed this; symptoms show up identically in every + language (Go returned a zero `Property`; Rust panics). + +--- + # Prompty v2 — Complete Rebuild Plan ## Overview @@ -414,7 +538,7 @@ Integration tests live in `runtime/python/prompty/tests/integration/` (see Phase | -------------------- | ------------------------------------------------------ | | `test_chat.py` | Chat completions against real OpenAI / Azure endpoints | | `test_embedding.py` | Embedding API against real endpoints | -| `test_image.py` | DALL-E 2 image generation (OpenAI only) | +| `test_image.py` | Image generation (OpenAI only; opt-in via `OPENAI_IMAGE_MODEL`) | | `test_agent.py` | Agent loop with tool calling against real endpoints | | `test_streaming.py` | Streaming chat completions | | `test_structured.py` | Structured output via outputs / response_format | @@ -690,10 +814,9 @@ tools: kind: function description: Get the current weather parameters: - properties: - location: - kind: string - description: City and state + location: + kind: string + description: City and state --- system: You are a helpful assistant with access to tools. @@ -1210,7 +1333,7 @@ Configured via `.env` in the package root (`runtime/python/prompty/.env`), alrea | -------------------- | ----- | --------------------------------------------------------- | | `test_chat.py` | 5 | Basic + async chat, temperature control, both providers | | `test_embedding.py` | 5 | Single + batch + async embeddings, both providers | -| `test_image.py` | 1 | DALL-E 2 image generation (OpenAI only, 256x256 for cost) | +| `test_image.py` | 1 | Image generation (OpenAI only; opt-in via `OPENAI_IMAGE_MODEL`) | | `test_agent.py` | 3 | Tool-calling agent loop, sync + async, both providers | | `test_streaming.py` | 3 | Streaming chat with PromptyStream/AsyncPromptyStream | | `test_structured.py` | 4 | Structured output via outputs → response_format | @@ -1254,11 +1377,14 @@ agent = make_openai_agent( "name": "get_weather", "kind": "function", "description": "Get the current weather", - "parameters": { - "properties": [ - {"name": "city", "kind": "string", "required": True} - ] - }, + # FunctionTool.parameters is a `Properties` named collection + # (schema/model/tools/tool.tsp). Use the declared list form. Wrapping it + # in {"properties": [...]} parses as name-keyed object form with an array + # under a key, which the loader rejects: + # "tools.parameters.properties: invalid named collection entry category array" + "parameters": [ + {"name": "city", "kind": "string", "required": True} + ], }], metadata={"tool_functions": {"get_weather": get_weather}}, ) diff --git a/.github/workflows/schema-repro-check.yml b/.github/workflows/schema-repro-check.yml new file mode 100644 index 000000000..dc69894fe --- /dev/null +++ b/.github/workflows/schema-repro-check.yml @@ -0,0 +1,102 @@ +name: schema generated-code reproducibility + +# Asserts that the committed generated code is exactly what the pinned +# @typra/emitter produces. Nothing enforced this before, which is how two +# commits on this repo (00a040fb -> 0.4.26, 38c7a5a0 -> 0.4.27) bumped the +# emitter pin without regenerating and ran zero CI: every runtime workflow +# is paths-filtered to runtime//**, so a schema-only commit triggered +# nothing. Both happened to be no-ops, but that was luck, not a gate. +on: + pull_request: + paths: + - 'schema/**' + - '.github/workflows/schema-repro-check.yml' + + workflow_call: + workflow_dispatch: + +jobs: + regenerate-is-idempotent: + name: regeneration produces no diff + runs-on: ubuntu-latest + permissions: + contents: read + env: + # runtime/python/prompty/uv.lock is tracked, and the Python emitter shells + # out to `uv run ruff`, which would otherwise re-lock and dirty the tree — + # reporting as generated-code staleness. Frozen turns that into an explicit + # lockfile error instead of a misattributed diff. + UV_FROZEN: '1' + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + # Generation is only reproducible when every formatter the emitter shells + # out to is present. Each one degrades to a warning when missing, so an + # absent formatter reports as a large cosmetic diff and is easily + # misread as stale generated code. All three are installed below, and + # the regenerate step fails on the warnings rather than trusting them. + # + # rustfmt normalize-typra-output.mjs runs `cargo fmt -p prompty` + # (the Rust emitter does not format its own output) + # prettier typescript/driver.js walks up from the TypeScript project + # root for node_modules/prettier/bin/prettier.cjs, then runs + # `npx eslint --fix` from the same workspace + # ruff python/driver.js runs `uv run ruff check --fix` and + # `uv run ruff format` from runtime/python/prompty + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Install TypeScript workspace (provides prettier and eslint) + working-directory: runtime/typescript + run: npm ci + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install Python dev tooling (provides ruff) + working-directory: runtime/python/prompty + run: | + uv venv + uv pip install -e ".[dev]" + + - name: Install schema dependencies + working-directory: schema + run: npm ci + + - name: Regenerate from the pinned emitter + working-directory: schema + run: | + set -o pipefail + npm run generate 2>&1 | tee "$RUNNER_TEMP/generate.log" + + # A missing formatter produces a warning and unformatted output. That + # is indistinguishable from stale generated code in the diff below, so + # fail here with the real cause instead of reporting false staleness. + if grep -Eq 'prettier not found|prettier formatting failed|ruff check failed|ruff format failed|did not run; generated Rust is unformatted' "$RUNNER_TEMP/generate.log"; then + echo "::error::A formatter was unavailable during generation, so the output is not comparable to the committed tree." + echo "This is an environment problem in this job, not stale generated code." + grep -E 'prettier|ruff|cargo fmt' "$RUNNER_TEMP/generate.log" || true + exit 1 + fi + + - name: Assert the tree is unchanged + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "::error::Regenerating from the pinned @typra/emitter changed the tree." + echo "The committed generated code does not match the pinned emitter version." + echo "Fix: run 'npm run generate' in schema/ and commit the result." + echo "" + echo "--- changed files ---" + git status --porcelain + echo "" + echo "--- diff ---" + git --no-pager diff --stat + exit 1 + fi + echo "Generated code matches the pinned emitter exactly." diff --git a/runtime/csharp/.env.example b/runtime/csharp/.env.example index 3d5e8cbe7..71d1a07c1 100644 --- a/runtime/csharp/.env.example +++ b/runtime/csharp/.env.example @@ -3,7 +3,10 @@ OPENAI_API_KEY= OPENAI_BASE_URL= OPENAI_MODEL=gpt-4o-mini OPENAI_EMBEDDING_MODEL=text-embedding-3-small -OPENAI_IMAGE_MODEL=dall-e-2 +# Image generation is opt-in and billable; leave blank to skip those tests. +# Model availability is account-specific: dall-e-2 / dall-e-3 are retired on +# current accounts (400 "does not exist"); newer accounts expose gpt-image-1. +OPENAI_IMAGE_MODEL= # Direct OpenAI DIRECT_OPENAI_API_KEY= diff --git a/runtime/csharp/Prompty.Anthropic.Tests/AnthropicExecutorTests.cs b/runtime/csharp/Prompty.Anthropic.Tests/AnthropicExecutorTests.cs index d73197cb3..63c0cdea5 100644 --- a/runtime/csharp/Prompty.Anthropic.Tests/AnthropicExecutorTests.cs +++ b/runtime/csharp/Prompty.Anthropic.Tests/AnthropicExecutorTests.cs @@ -9,6 +9,17 @@ namespace Prompty.Anthropic.Tests; /// public class AnthropicExecutorTests { + [Fact] + public async Task ExecuteAsync_Cancelled_ThrowsBeforeConnectionValidation() + { + var executor = new Anthropic.AnthropicExecutor(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + () => executor.ExecuteAsync(new Core.Prompty(), [], cancellation.Token)); + } + [Fact] public async Task ExecuteAsync_MissingApiKey_ThrowsInvalidOperationException() { @@ -156,4 +167,3 @@ public void FormatToolMessages_NoTextContent_OmitsTextBlock() Assert.Equal("tool_use", content[0]["type"]); } } - diff --git a/runtime/csharp/Prompty.Anthropic/AnthropicExecutor.cs b/runtime/csharp/Prompty.Anthropic/AnthropicExecutor.cs index a64f6198d..5b6117e6a 100644 --- a/runtime/csharp/Prompty.Anthropic/AnthropicExecutor.cs +++ b/runtime/csharp/Prompty.Anthropic/AnthropicExecutor.cs @@ -19,30 +19,40 @@ public class AnthropicExecutor : IExecutor private const string ApiVersion = "2023-06-01"; private const int DefaultMaxTokens = 4096; - public async Task ExecuteAsync(Core.Prompty agent, List messages) + public async Task ExecuteAsync( + Core.Prompty agent, + List messages, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var streaming = agent.Metadata?.TryGetValue("stream", out var streamVal) == true && streamVal is true; if (streaming) - return ExecuteStreamAsync(agent, messages); + return ExecuteStreamAsync(agent, messages, cancellationToken); - return await ExecuteNonStreamAsync(agent, messages); + return await ExecuteNonStreamAsync(agent, messages, cancellationToken); } - private async Task ExecuteNonStreamAsync(Core.Prompty agent, List messages) + private async Task ExecuteNonStreamAsync( + Core.Prompty agent, + List messages, + CancellationToken cancellationToken) { var body = BuildRequestBody(agent, messages, stream: false); var (endpoint, apiKey) = GetConnectionInfo(agent); var request = CreateRequest(endpoint, apiKey, body); - var response = await _httpClient.SendAsync(request); + var response = await _httpClient.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadFromJsonAsync(); + var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); return json; } - private PromptyStream ExecuteStreamAsync(Core.Prompty agent, List messages) + private PromptyStream ExecuteStreamAsync( + Core.Prompty agent, + List messages, + CancellationToken cancellationToken) { var body = BuildRequestBody(agent, messages, stream: true); var (endpoint, apiKey) = GetConnectionInfo(agent); @@ -69,7 +79,7 @@ async IAsyncEnumerable StreamEvents([System.Runtime.CompilerServices.Enu } } - return new PromptyStream(StreamEvents()); + return new PromptyStream(StreamEvents(cancellationToken)); } internal Dictionary BuildRequestBody(Core.Prompty agent, List messages, bool stream) diff --git a/runtime/csharp/Prompty.Core.Tests/AgentLoopIntegrationTests.cs b/runtime/csharp/Prompty.Core.Tests/AgentLoopIntegrationTests.cs index ce0bcb19a..c84974bb7 100644 --- a/runtime/csharp/Prompty.Core.Tests/AgentLoopIntegrationTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/AgentLoopIntegrationTests.cs @@ -64,7 +64,10 @@ public Task> ParseAsync(Prompty agent, string rendered, Dictionary public void EnqueueResponse(object response) => _responses.Enqueue(response); - public Task ExecuteAsync(Prompty agent, List messages) + public Task ExecuteAsync( + Prompty agent, + List messages, + CancellationToken cancellationToken = default) { // Snapshot the messages at call time Calls.Add(new List(messages)); diff --git a/runtime/csharp/Prompty.Core.Tests/ConnectionRoundtripVectorTests.cs b/runtime/csharp/Prompty.Core.Tests/ConnectionRoundtripVectorTests.cs new file mode 100644 index 000000000..166739bbe --- /dev/null +++ b/runtime/csharp/Prompty.Core.Tests/ConnectionRoundtripVectorTests.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Prompty.Core; + +namespace Prompty.Core.Tests; + +public class ConnectionRoundtripVectorTests +{ + private static readonly string VectorsPath = FindVectorsPath(); + + [Theory] + [InlineData("known_reference_connection_roundtrip_unchanged")] + [InlineData("unknown_connection_kind_preserves_payload")] + [InlineData("unknown_connection_case_collision_preserves_payload")] + public void ConnectionRoundtripVectors_PreserveExactDiscriminatorAndPayload(string vectorName) + { + using var document = JsonDocument.Parse(File.ReadAllText(VectorsPath)); + var vector = document.RootElement + .GetProperty("vectors") + .EnumerateArray() + .Single(candidate => candidate.GetProperty("name").GetString() == vectorName); + + var input = vector.GetProperty("input"); + var expected = vector.GetProperty("expected"); + var expectedKind = expected.GetProperty("kind").GetString()!; + var data = JsonSerializer.Deserialize>(input.GetRawText())!; + + var loaded = Connection.Load(data); + if (expectedKind == "reference") + Assert.IsType(loaded); + else + Assert.IsNotType(loaded); + + var saved = loaded.Save(); + Assert.Equal(expectedKind, saved["kind"]); + AssertJsonEqual(vectorName, "save", expected, saved); + + var reloaded = Connection.Load(saved); + var resaved = reloaded.Save(); + Assert.Equal(expectedKind, resaved["kind"]); + AssertJsonEqual(vectorName, "reload", expected, resaved); + } + + private static void AssertJsonEqual( + string vectorName, + string operation, + JsonElement expected, + Dictionary actual) + { + var expectedNode = JsonNode.Parse(expected.GetRawText()); + var actualNode = JsonNode.Parse(JsonSerializer.Serialize(actual)); + Assert.True( + JsonNode.DeepEquals(expectedNode, actualNode), + $"[{vectorName}] {operation} changed the Connection payload.\nExpected: {expectedNode}\nActual: {actualNode}"); + } + + private static string FindVectorsPath() + { + var directory = AppContext.BaseDirectory; + for (var i = 0; i < 10; i++) + { + var candidate = Path.Combine( + directory, + "spec", + "vectors", + "model", + "connection_roundtrip_vectors.json"); + if (File.Exists(candidate)) + return candidate; + + directory = Path.GetDirectoryName(directory) ?? directory; + } + + throw new FileNotFoundException("Could not locate the shared Connection roundtrip vectors."); + } +} diff --git a/runtime/csharp/Prompty.Core.Tests/ContentPartDiscriminatorVectorTests.cs b/runtime/csharp/Prompty.Core.Tests/ContentPartDiscriminatorVectorTests.cs new file mode 100644 index 000000000..1f25ec2ee --- /dev/null +++ b/runtime/csharp/Prompty.Core.Tests/ContentPartDiscriminatorVectorTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Prompty.Core; + +namespace Prompty.Core.Tests; + +public class ContentPartDiscriminatorVectorTests +{ + private static readonly string VectorsPath = FindVectorsPath(); + + [Theory] + [InlineData("known_text_content_part_loads")] + [InlineData("unknown_content_part_kind_is_rejected")] + [InlineData("content_part_case_collision_is_rejected")] + public void ContentPartDiscriminatorVectors_EnforceClosedCaseSensitiveKinds(string vectorName) + { + using var document = JsonDocument.Parse(File.ReadAllText(VectorsPath)); + var vector = document.RootElement + .GetProperty("vectors") + .EnumerateArray() + .Single(candidate => candidate.GetProperty("name").GetString() == vectorName); + var input = vector.GetProperty("input"); + var expected = vector.GetProperty("expected"); + var data = JsonSerializer.Deserialize>(input.GetRawText())!; + + switch (vector.GetProperty("operation").GetString()) + { + case "load": + var loaded = ContentPart.Load(data); + Assert.IsType(loaded); + AssertJsonEqual(vectorName, expected, loaded.Save()); + break; + case "load-error": + var exception = Assert.ThrowsAny(() => ContentPart.Load(data)); + Assert.Contains(expected.GetProperty("discriminator").GetString()!, exception.Message); + Assert.Contains(expected.GetProperty("value").GetString()!, exception.Message); + break; + default: + throw new InvalidOperationException($"[{vectorName}] unsupported vector operation."); + } + } + + private static void AssertJsonEqual( + string vectorName, + JsonElement expected, + Dictionary actual) + { + var expectedNode = JsonNode.Parse(expected.GetRawText()); + var actualNode = JsonNode.Parse(JsonSerializer.Serialize(actual)); + Assert.True( + JsonNode.DeepEquals(expectedNode, actualNode), + $"[{vectorName}] load/save changed the ContentPart payload.\nExpected: {expectedNode}\nActual: {actualNode}"); + } + + private static string FindVectorsPath() + { + var directory = AppContext.BaseDirectory; + for (var i = 0; i < 10; i++) + { + var candidate = Path.Combine( + directory, + "spec", + "vectors", + "model", + "content_part_discriminator_vectors.json"); + if (File.Exists(candidate)) + return candidate; + + directory = Path.GetDirectoryName(directory) ?? directory; + } + + throw new FileNotFoundException("Could not locate the shared ContentPart discriminator vectors."); + } +} diff --git a/runtime/csharp/Prompty.Core.Tests/Model/agent/GuardrailResultConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/agent/GuardrailResultConversionTests.cs index 8417ac6fe..0d80e520b 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/agent/GuardrailResultConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/agent/GuardrailResultConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/agent/PromptyConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/agent/PromptyConversionTests.cs index af121fe9f..4d7aea2a9 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/agent/PromptyConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/agent/PromptyConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 @@ -85,17 +87,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -174,17 +166,7 @@ public void LoadJsonInput() Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -270,17 +252,7 @@ public void RoundtripJson() Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] @@ -366,17 +338,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] @@ -614,17 +576,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -702,17 +654,7 @@ public void LoadJsonInput1() Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -797,17 +739,7 @@ public void RoundtripJson1() Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] @@ -893,17 +825,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] @@ -1140,17 +1062,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -1230,17 +1142,7 @@ public void LoadJsonInput2() Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -1327,17 +1229,7 @@ public void RoundtripJson2() Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] @@ -1423,17 +1315,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] @@ -1672,17 +1554,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -1761,17 +1633,7 @@ public void LoadJsonInput3() Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -1857,17 +1719,7 @@ public void RoundtripJson3() Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] @@ -1953,17 +1805,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] @@ -2201,17 +2043,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -2293,17 +2125,7 @@ public void LoadJsonInput4() Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -2392,17 +2214,7 @@ public void RoundtripJson4() Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] @@ -2488,17 +2300,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] @@ -2739,17 +2541,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -2830,17 +2622,7 @@ public void LoadJsonInput5() Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -2928,17 +2710,7 @@ public void RoundtripJson5() Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] @@ -3024,17 +2796,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] @@ -3274,17 +3036,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -3367,17 +3119,7 @@ public void LoadJsonInput6() Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -3467,17 +3209,7 @@ public void RoundtripJson6() Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] @@ -3563,17 +3295,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] @@ -3815,17 +3537,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -3907,17 +3619,7 @@ public void LoadJsonInput7() Assert.Equal("basic-prompt", instance.Name); Assert.Equal("Basic Prompt", instance.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", instance.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), instance.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", instance.Instructions); } [Fact] @@ -4006,17 +3708,7 @@ public void RoundtripJson7() Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] @@ -4102,17 +3794,7 @@ their questions. Use their name to address them in your responses. Assert.Equal("basic-prompt", reloaded.Name); Assert.Equal("Basic Prompt", reloaded.DisplayName); Assert.Equal("A basic prompt that uses the GPT-3 chat API to answer questions", reloaded.Description); - Assert.Equal(@"system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}".Replace("\r\n", "\n"), reloaded.Instructions); + Assert.Equal("system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some\npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to\ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}", reloaded.Instructions); } [Fact] diff --git a/runtime/csharp/Prompty.Core.Tests/Model/connection/AnonymousConnectionConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/connection/AnonymousConnectionConversionTests.cs index f7213647e..0f0f4e5dc 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/connection/AnonymousConnectionConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/connection/AnonymousConnectionConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/connection/ApiKeyConnectionConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/connection/ApiKeyConnectionConversionTests.cs index c6c0e9306..1b0a3198a 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/connection/ApiKeyConnectionConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/connection/ApiKeyConnectionConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/connection/AuthorizationCodeFlowConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/connection/AuthorizationCodeFlowConversionTests.cs index 99a90ad97..4f78b3901 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/connection/AuthorizationCodeFlowConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/connection/AuthorizationCodeFlowConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/connection/ConnectionConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/connection/ConnectionConversionTests.cs index ec537940c..8e1437097 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/connection/ConnectionConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/connection/ConnectionConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/connection/DeviceAuthorizationConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/connection/DeviceAuthorizationConversionTests.cs index bb623e9c1..226b0f9ae 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/connection/DeviceAuthorizationConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/connection/DeviceAuthorizationConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/connection/FoundryConnectionConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/connection/FoundryConnectionConversionTests.cs index 273154e6b..fcae9f725 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/connection/FoundryConnectionConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/connection/FoundryConnectionConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/connection/OAuthConnectionConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/connection/OAuthConnectionConversionTests.cs index 01f2f8c67..1eb0dbe31 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/connection/OAuthConnectionConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/connection/OAuthConnectionConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/connection/OAuthTokenConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/connection/OAuthTokenConversionTests.cs index e5ebf53d1..5fc0f1eef 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/connection/OAuthTokenConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/connection/OAuthTokenConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/connection/ReferenceConnectionConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/connection/ReferenceConnectionConversionTests.cs index 35ea856e9..f176e6944 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/connection/ReferenceConnectionConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/connection/ReferenceConnectionConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/connection/RemoteConnectionConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/connection/RemoteConnectionConversionTests.cs index b5ddf396a..6ccf105a2 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/connection/RemoteConnectionConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/connection/RemoteConnectionConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/conversation/AudioPartConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/conversation/AudioPartConversionTests.cs index fbdacb651..a850e5a53 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/conversation/AudioPartConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/conversation/AudioPartConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/conversation/ContentPartConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/conversation/ContentPartConversionTests.cs index b06bb5578..2920a75e9 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/conversation/ContentPartConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/conversation/ContentPartConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/conversation/FilePartConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/conversation/FilePartConversionTests.cs index 7db42fa57..7cb996187 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/conversation/FilePartConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/conversation/FilePartConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/conversation/ImagePartConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/conversation/ImagePartConversionTests.cs index 1fb3d3889..627f0d049 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/conversation/ImagePartConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/conversation/ImagePartConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/conversation/MessageConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/conversation/MessageConversionTests.cs index 7fcb2de53..6204a1a7e 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/conversation/MessageConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/conversation/MessageConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/conversation/TextPartConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/conversation/TextPartConversionTests.cs index 8fc113595..e67a22322 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/conversation/TextPartConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/conversation/TextPartConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/conversation/ThreadMarkerConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/conversation/ThreadMarkerConversionTests.cs index c5edf1d8b..4d9fc7046 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/conversation/ThreadMarkerConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/conversation/ThreadMarkerConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/conversation/ToolCallConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/conversation/ToolCallConversionTests.cs index 9b7fc0f70..2bac5a5f4 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/conversation/ToolCallConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/conversation/ToolCallConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 @@ -23,7 +25,7 @@ public void LoadYamlInput() Assert.NotNull(instance); Assert.Equal("call_abc123", instance.Id); Assert.Equal("get_weather", instance.Name); - Assert.Equal(@"{""city"": ""Paris""}".Replace("\r\n", "\n"), instance.Arguments); + Assert.Equal("{\"city\": \"Paris\"}", instance.Arguments); } [Fact] @@ -41,7 +43,7 @@ public void LoadJsonInput() Assert.NotNull(instance); Assert.Equal("call_abc123", instance.Id); Assert.Equal("get_weather", instance.Name); - Assert.Equal(@"{""city"": ""Paris""}".Replace("\r\n", "\n"), instance.Arguments); + Assert.Equal("{\"city\": \"Paris\"}", instance.Arguments); } [Fact] @@ -66,7 +68,7 @@ public void RoundtripJson() Assert.NotNull(reloaded); Assert.Equal("call_abc123", reloaded.Id); Assert.Equal("get_weather", reloaded.Name); - Assert.Equal(@"{""city"": ""Paris""}".Replace("\r\n", "\n"), reloaded.Arguments); + Assert.Equal("{\"city\": \"Paris\"}", reloaded.Arguments); } [Fact] @@ -90,7 +92,7 @@ public void RoundtripYaml() Assert.NotNull(reloaded); Assert.Equal("call_abc123", reloaded.Id); Assert.Equal("get_weather", reloaded.Name); - Assert.Equal(@"{""city"": ""Paris""}".Replace("\r\n", "\n"), reloaded.Arguments); + Assert.Equal("{\"city\": \"Paris\"}", reloaded.Arguments); } [Fact] diff --git a/runtime/csharp/Prompty.Core.Tests/Model/conversation/ToolResultConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/conversation/ToolResultConversionTests.cs index aaee6c25e..6d12382c2 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/conversation/ToolResultConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/conversation/ToolResultConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/core/ArrayPropertyConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/core/ArrayPropertyConversionTests.cs index 0a6855400..3fd23da75 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/core/ArrayPropertyConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/core/ArrayPropertyConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/core/FileNotFoundErrorConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/core/FileNotFoundErrorConversionTests.cs index e0b6294df..13a9c234b 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/core/FileNotFoundErrorConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/core/FileNotFoundErrorConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/core/InvokerErrorConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/core/InvokerErrorConversionTests.cs index 3e1b3a876..ffd06cc2b 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/core/InvokerErrorConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/core/InvokerErrorConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/core/ObjectPropertyConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/core/ObjectPropertyConversionTests.cs index 1a7098e20..f668f283d 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/core/ObjectPropertyConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/core/ObjectPropertyConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/core/PropertyConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/core/PropertyConversionTests.cs index 0fba692c1..67f7618aa 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/core/PropertyConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/core/PropertyConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/core/UnionPropertyConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/core/UnionPropertyConversionTests.cs index 074972965..3c5e86398 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/core/UnionPropertyConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/core/UnionPropertyConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/core/ValidationErrorConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/core/ValidationErrorConversionTests.cs index 9cb2602cc..b604f2104 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/core/ValidationErrorConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/core/ValidationErrorConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/core/ValidationResultConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/core/ValidationResultConversionTests.cs index 11d70897d..fa1e8c0b0 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/core/ValidationResultConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/core/ValidationResultConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/CheckpointConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/CheckpointConversionTests.cs index 93a9d499d..4aa293acc 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/CheckpointConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/CheckpointConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/CompactionCompletePayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/CompactionCompletePayloadConversionTests.cs index 63c74abc5..65e2fc2fb 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/CompactionCompletePayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/CompactionCompletePayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/CompactionFailedPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/CompactionFailedPayloadConversionTests.cs index d0dbd17cc..8a9447d21 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/CompactionFailedPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/CompactionFailedPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/CompactionStartPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/CompactionStartPayloadConversionTests.cs index f7694b705..ce8b01e59 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/CompactionStartPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/CompactionStartPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/DoneEventPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/DoneEventPayloadConversionTests.cs index e35c00c1b..845739e19 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/DoneEventPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/DoneEventPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/ErrorChunkConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/ErrorChunkConversionTests.cs index 31b0f8a9a..74534f657 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/ErrorChunkConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/ErrorChunkConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/ErrorEventPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/ErrorEventPayloadConversionTests.cs index 86ed0e37f..dd05126f9 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/ErrorEventPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/ErrorEventPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/HarnessContextConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/HarnessContextConversionTests.cs index d5a60b404..bc768c474 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/HarnessContextConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/HarnessContextConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/HookEndPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/HookEndPayloadConversionTests.cs index aa269b828..c39baf08d 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/HookEndPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/HookEndPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/HookStartPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/HookStartPayloadConversionTests.cs index 5bfed0604..7348c4925 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/HookStartPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/HookStartPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/HostToolRequestConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/HostToolRequestConversionTests.cs index d58ddf876..a547e67e4 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/HostToolRequestConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/HostToolRequestConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/HostToolResultConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/HostToolResultConversionTests.cs index e503660f6..2895faabd 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/HostToolResultConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/HostToolResultConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/LlmCompletePayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/LlmCompletePayloadConversionTests.cs index 8766f3e4c..3ba369ef9 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/LlmCompletePayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/LlmCompletePayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/LlmStartPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/LlmStartPayloadConversionTests.cs index 35fde0a5f..8dcf7a4f8 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/LlmStartPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/LlmStartPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/MessagesUpdatedPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/MessagesUpdatedPayloadConversionTests.cs index 8b08f6c11..65f64b566 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/MessagesUpdatedPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/MessagesUpdatedPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionCompletedPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionCompletedPayloadConversionTests.cs index 0571c51fd..2ba5abcf1 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionCompletedPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionCompletedPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionDecisionConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionDecisionConversionTests.cs index 559a177ae..a244d6033 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionDecisionConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionDecisionConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionRequestConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionRequestConversionTests.cs index 34c744c94..02e3de209 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionRequestConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionRequestConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionRequestedPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionRequestedPayloadConversionTests.cs index 5a7018aa7..320e5a58a 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionRequestedPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/PermissionRequestedPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/RedactedFieldConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/RedactedFieldConversionTests.cs index 4bfe1f7f8..69625e89a 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/RedactedFieldConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/RedactedFieldConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/RedactionMetadataConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/RedactionMetadataConversionTests.cs index ed02a096d..2d99f4896 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/RedactionMetadataConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/RedactionMetadataConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/RetryPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/RetryPayloadConversionTests.cs index 8a65c910f..2f7f5aca2 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/RetryPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/RetryPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionEndPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionEndPayloadConversionTests.cs index 704cead53..974660161 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionEndPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionEndPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionEventConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionEventConversionTests.cs index 3998e0421..50fcf44c3 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionEventConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionEventConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionFileRefConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionFileRefConversionTests.cs index 34cb804c0..90b67b663 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionFileRefConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionFileRefConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionRefConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionRefConversionTests.cs index f074aee79..4ead54929 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionRefConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionRefConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionStartPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionStartPayloadConversionTests.cs index 1816aab70..351bccf7c 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionStartPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionStartPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionSummaryConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionSummaryConversionTests.cs index 211e92e43..a2937b65f 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionSummaryConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionSummaryConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionTraceConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionTraceConversionTests.cs index fea885d88..183f059fe 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionTraceConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionTraceConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 @@ -16,6 +18,14 @@ public void LoadYamlInput() runtime: typescript promptyVersion: 2.0.0 sessionId: sess_abc123 +events: + - id: evt_abc123 + type: session_start + timestamp: "2026-06-09T20:00:00Z" + sessionId: sess_abc123 + turnId: turn_001 + parentId: evt_parent + spanId: span_hook_001 """; @@ -36,7 +46,18 @@ public void LoadJsonInput() "version": "1", "runtime": "typescript", "promptyVersion": "2.0.0", - "sessionId": "sess_abc123" + "sessionId": "sess_abc123", + "events": [ + { + "id": "evt_abc123", + "type": "session_start", + "timestamp": "2026-06-09T20:00:00Z", + "sessionId": "sess_abc123", + "turnId": "turn_001", + "parentId": "evt_parent", + "spanId": "span_hook_001" + } + ] } """; @@ -57,7 +78,18 @@ public void RoundtripJson() "version": "1", "runtime": "typescript", "promptyVersion": "2.0.0", - "sessionId": "sess_abc123" + "sessionId": "sess_abc123", + "events": [ + { + "id": "evt_abc123", + "type": "session_start", + "timestamp": "2026-06-09T20:00:00Z", + "sessionId": "sess_abc123", + "turnId": "turn_001", + "parentId": "evt_parent", + "spanId": "span_hook_001" + } + ] } """; @@ -84,6 +116,14 @@ public void RoundtripYaml() runtime: typescript promptyVersion: 2.0.0 sessionId: sess_abc123 +events: + - id: evt_abc123 + type: session_start + timestamp: "2026-06-09T20:00:00Z" + sessionId: sess_abc123 + turnId: turn_001 + parentId: evt_parent + spanId: span_hook_001 """; @@ -109,7 +149,18 @@ public void ToJsonProducesValidJson() "version": "1", "runtime": "typescript", "promptyVersion": "2.0.0", - "sessionId": "sess_abc123" + "sessionId": "sess_abc123", + "events": [ + { + "id": "evt_abc123", + "type": "session_start", + "timestamp": "2026-06-09T20:00:00Z", + "sessionId": "sess_abc123", + "turnId": "turn_001", + "parentId": "evt_parent", + "spanId": "span_hook_001" + } + ] } """; @@ -129,6 +180,14 @@ public void ToYamlProducesValidYaml() runtime: typescript promptyVersion: 2.0.0 sessionId: sess_abc123 +events: + - id: evt_abc123 + type: session_start + timestamp: "2026-06-09T20:00:00Z" + sessionId: sess_abc123 + turnId: turn_001 + parentId: evt_parent + spanId: span_hook_001 """; diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionWarningPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionWarningPayloadConversionTests.cs index 8cdfdcb2d..2c1b107b2 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/SessionWarningPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/SessionWarningPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/StatusEventPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/StatusEventPayloadConversionTests.cs index ec617bb1c..cbeded877 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/StatusEventPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/StatusEventPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/StreamChunkConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/StreamChunkConversionTests.cs index 2f68fa7dd..30cc531e4 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/StreamChunkConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/StreamChunkConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/TextChunkConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/TextChunkConversionTests.cs index fb7a2b637..a350368d2 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/TextChunkConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/TextChunkConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/ThinkingChunkConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/ThinkingChunkConversionTests.cs index 5b7977e4f..926ecac9c 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/ThinkingChunkConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/ThinkingChunkConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/ThinkingEventPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/ThinkingEventPayloadConversionTests.cs index 2916d38b7..80fe4552e 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/ThinkingEventPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/ThinkingEventPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/TokenEventPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/TokenEventPayloadConversionTests.cs index 5f8f3e1be..9f72fbfdb 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/TokenEventPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/TokenEventPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/ToolCallCompletePayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/ToolCallCompletePayloadConversionTests.cs index 256ab85a9..e7c15d3ed 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/ToolCallCompletePayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/ToolCallCompletePayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/ToolCallStartPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/ToolCallStartPayloadConversionTests.cs index b8d098a6b..c305ea263 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/ToolCallStartPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/ToolCallStartPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 @@ -23,7 +25,7 @@ public void LoadYamlInput() Assert.NotNull(instance); Assert.Equal("call_abc123", instance.Id); Assert.Equal("get_weather", instance.Name); - Assert.Equal(@"{""city"": ""Paris""}".Replace("\r\n", "\n"), instance.Arguments); + Assert.Equal("{\"city\": \"Paris\"}", instance.Arguments); } [Fact] @@ -41,7 +43,7 @@ public void LoadJsonInput() Assert.NotNull(instance); Assert.Equal("call_abc123", instance.Id); Assert.Equal("get_weather", instance.Name); - Assert.Equal(@"{""city"": ""Paris""}".Replace("\r\n", "\n"), instance.Arguments); + Assert.Equal("{\"city\": \"Paris\"}", instance.Arguments); } [Fact] @@ -66,7 +68,7 @@ public void RoundtripJson() Assert.NotNull(reloaded); Assert.Equal("call_abc123", reloaded.Id); Assert.Equal("get_weather", reloaded.Name); - Assert.Equal(@"{""city"": ""Paris""}".Replace("\r\n", "\n"), reloaded.Arguments); + Assert.Equal("{\"city\": \"Paris\"}", reloaded.Arguments); } [Fact] @@ -90,7 +92,7 @@ public void RoundtripYaml() Assert.NotNull(reloaded); Assert.Equal("call_abc123", reloaded.Id); Assert.Equal("get_weather", reloaded.Name); - Assert.Equal(@"{""city"": ""Paris""}".Replace("\r\n", "\n"), reloaded.Arguments); + Assert.Equal("{\"city\": \"Paris\"}", reloaded.Arguments); } [Fact] diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/ToolChunkConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/ToolChunkConversionTests.cs index 418619962..36396613b 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/ToolChunkConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/ToolChunkConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/ToolExecutionCompletePayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/ToolExecutionCompletePayloadConversionTests.cs index 8cd34fd97..3db093f4e 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/ToolExecutionCompletePayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/ToolExecutionCompletePayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/ToolExecutionStartPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/ToolExecutionStartPayloadConversionTests.cs index 64a6adfb4..e6f770bae 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/ToolExecutionStartPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/ToolExecutionStartPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/ToolResultPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/ToolResultPayloadConversionTests.cs index d0307262c..8cbc90f34 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/ToolResultPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/ToolResultPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/TrajectoryEventConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/TrajectoryEventConversionTests.cs index 7c5bf5700..35fbb855b 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/TrajectoryEventConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/TrajectoryEventConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/TurnEndPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/TurnEndPayloadConversionTests.cs index ec38407d5..ef89b2a80 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/TurnEndPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/TurnEndPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/TurnEventConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/TurnEventConversionTests.cs index 9aabec4e5..115e28a88 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/TurnEventConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/TurnEventConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/TurnStartPayloadConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/TurnStartPayloadConversionTests.cs index e832830d5..401d60d07 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/TurnStartPayloadConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/TurnStartPayloadConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/TurnSummaryConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/TurnSummaryConversionTests.cs index 829809e6f..88a3be745 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/TurnSummaryConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/TurnSummaryConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/TurnTraceConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/TurnTraceConversionTests.cs index 8494ef203..ba46ccd60 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/TurnTraceConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/TurnTraceConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 @@ -15,6 +17,14 @@ public void LoadYamlInput() version: "1" runtime: typescript promptyVersion: 2.0.0 +events: + - id: evt_abc123 + type: turn_start + timestamp: "2026-06-09T20:00:00Z" + turnId: turn_001 + iteration: 0 + parentId: evt_parent + spanId: span_tool_001 """; @@ -33,7 +43,18 @@ public void LoadJsonInput() { "version": "1", "runtime": "typescript", - "promptyVersion": "2.0.0" + "promptyVersion": "2.0.0", + "events": [ + { + "id": "evt_abc123", + "type": "turn_start", + "timestamp": "2026-06-09T20:00:00Z", + "turnId": "turn_001", + "iteration": 0, + "parentId": "evt_parent", + "spanId": "span_tool_001" + } + ] } """; @@ -52,7 +73,18 @@ public void RoundtripJson() { "version": "1", "runtime": "typescript", - "promptyVersion": "2.0.0" + "promptyVersion": "2.0.0", + "events": [ + { + "id": "evt_abc123", + "type": "turn_start", + "timestamp": "2026-06-09T20:00:00Z", + "turnId": "turn_001", + "iteration": 0, + "parentId": "evt_parent", + "spanId": "span_tool_001" + } + ] } """; @@ -77,6 +109,14 @@ public void RoundtripYaml() version: "1" runtime: typescript promptyVersion: 2.0.0 +events: + - id: evt_abc123 + type: turn_start + timestamp: "2026-06-09T20:00:00Z" + turnId: turn_001 + iteration: 0 + parentId: evt_parent + spanId: span_tool_001 """; @@ -100,7 +140,18 @@ public void ToJsonProducesValidJson() { "version": "1", "runtime": "typescript", - "promptyVersion": "2.0.0" + "promptyVersion": "2.0.0", + "events": [ + { + "id": "evt_abc123", + "type": "turn_start", + "timestamp": "2026-06-09T20:00:00Z", + "turnId": "turn_001", + "iteration": 0, + "parentId": "evt_parent", + "spanId": "span_tool_001" + } + ] } """; @@ -119,6 +170,14 @@ public void ToYamlProducesValidYaml() version: "1" runtime: typescript promptyVersion: 2.0.0 +events: + - id: evt_abc123 + type: turn_start + timestamp: "2026-06-09T20:00:00Z" + turnId: turn_001 + iteration: 0 + parentId: evt_parent + spanId: span_tool_001 """; diff --git a/runtime/csharp/Prompty.Core.Tests/Model/events/UsageChunkConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/events/UsageChunkConversionTests.cs index 53c39d700..8e9a47561 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/events/UsageChunkConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/events/UsageChunkConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/memory/MemoryEntryConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/memory/MemoryEntryConversionTests.cs index 49bd63e0d..80be8c7a5 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/memory/MemoryEntryConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/memory/MemoryEntryConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/memory/MemoryStoreConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/memory/MemoryStoreConversionTests.cs index ef44bfa33..0219797f2 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/memory/MemoryStoreConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/memory/MemoryStoreConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/model/AiResourceInfoConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/model/AiResourceInfoConversionTests.cs index 8906337c6..aab26b84a 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/model/AiResourceInfoConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/model/AiResourceInfoConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/model/InvocationUsageConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/model/InvocationUsageConversionTests.cs index 641b82a45..9dd122d8f 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/model/InvocationUsageConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/model/InvocationUsageConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/model/ModelConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/model/ModelConversionTests.cs index ed993e2af..4c7eb4992 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/model/ModelConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/model/ModelConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/model/ModelInfoConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/model/ModelInfoConversionTests.cs index 3873758e3..1bbd05fb7 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/model/ModelInfoConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/model/ModelInfoConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/model/ModelOptionsConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/model/ModelOptionsConversionTests.cs index 34cbea93d..13038901c 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/model/ModelOptionsConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/model/ModelOptionsConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/model/ProjectInfoConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/model/ProjectInfoConversionTests.cs index 6b73a87cb..c53388ae8 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/model/ProjectInfoConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/model/ProjectInfoConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/model/SubscriptionInfoConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/model/SubscriptionInfoConversionTests.cs index 4df9cc6e9..a0f6ca73f 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/model/SubscriptionInfoConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/model/SubscriptionInfoConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/model/TokenUsageConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/model/TokenUsageConversionTests.cs index 4e57f030b..e42a9ac1e 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/model/TokenUsageConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/model/TokenUsageConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/CompactionConfigConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/CompactionConfigConversionTests.cs index 041b2ea45..8b1280a03 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/CompactionConfigConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/CompactionConfigConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ContextCandidateConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ContextCandidateConversionTests.cs index 470ce3a52..7b38619ae 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ContextCandidateConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ContextCandidateConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ContextRequestConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ContextRequestConversionTests.cs index e80018004..525cc3ff8 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ContextRequestConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ContextRequestConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/DelegatedStateReferenceConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/DelegatedStateReferenceConversionTests.cs index 61df38b02..4b07c7934 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/DelegatedStateReferenceConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/DelegatedStateReferenceConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/EngineCheckpointConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/EngineCheckpointConversionTests.cs index b6f14bfef..f0d2158e7 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/EngineCheckpointConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/EngineCheckpointConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 @@ -16,6 +18,7 @@ public void LoadYamlInput() sessionId: sess_abc123 turnId: turn_abc123 runId: run_abc123 +contextState: {} """; @@ -36,7 +39,8 @@ public void LoadJsonInput() "id": "ckpt_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "runId": "run_abc123" + "runId": "run_abc123", + "contextState": {} } """; @@ -57,7 +61,8 @@ public void RoundtripJson() "id": "ckpt_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "runId": "run_abc123" + "runId": "run_abc123", + "contextState": {} } """; @@ -84,6 +89,7 @@ public void RoundtripYaml() sessionId: sess_abc123 turnId: turn_abc123 runId: run_abc123 +contextState: {} """; @@ -109,7 +115,8 @@ public void ToJsonProducesValidJson() "id": "ckpt_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "runId": "run_abc123" + "runId": "run_abc123", + "contextState": {} } """; @@ -129,6 +136,7 @@ public void ToYamlProducesValidYaml() sessionId: sess_abc123 turnId: turn_abc123 runId: run_abc123 +contextState: {} """; diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/EngineEventConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/EngineEventConversionTests.cs index b2cec3766..b16bb7254 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/EngineEventConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/EngineEventConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/EnginePermissionDecisionConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/EnginePermissionDecisionConversionTests.cs index b51d045f4..b1ba432fe 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/EnginePermissionDecisionConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/EnginePermissionDecisionConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/FinalOutputPolicyRequestConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/FinalOutputPolicyRequestConversionTests.cs index 631904661..dac7d01fa 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/FinalOutputPolicyRequestConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/FinalOutputPolicyRequestConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/FinalOutputPolicyResultConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/FinalOutputPolicyResultConversionTests.cs index bd1e4b9dd..52fd0f1f8 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/FinalOutputPolicyResultConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/FinalOutputPolicyResultConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/HostPolicyRequestConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/HostPolicyRequestConversionTests.cs index a57e55bf6..aca0a6459 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/HostPolicyRequestConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/HostPolicyRequestConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/HostPolicyResultConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/HostPolicyResultConversionTests.cs index 655eb9cea..d06053b9f 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/HostPolicyResultConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/HostPolicyResultConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/InvocationContextDecisionConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/InvocationContextDecisionConversionTests.cs index 9b97e02fd..4b84097f8 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/InvocationContextDecisionConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/InvocationContextDecisionConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/InvocationContextStateConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/InvocationContextStateConversionTests.cs index d0f3abdbe..09573b1be 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/InvocationContextStateConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/InvocationContextStateConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelInvocationContextSnapshotConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelInvocationContextSnapshotConversionTests.cs index 383ebd03d..88d008847 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelInvocationContextSnapshotConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelInvocationContextSnapshotConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 @@ -16,6 +18,7 @@ public void LoadYamlInput() sessionId: sess_abc123 turnId: turn_abc123 invocationId: inv_abc123 +contextState: {} """; @@ -36,7 +39,8 @@ public void LoadJsonInput() "id": "context:inv_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "invocationId": "inv_abc123" + "invocationId": "inv_abc123", + "contextState": {} } """; @@ -57,7 +61,8 @@ public void RoundtripJson() "id": "context:inv_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "invocationId": "inv_abc123" + "invocationId": "inv_abc123", + "contextState": {} } """; @@ -84,6 +89,7 @@ public void RoundtripYaml() sessionId: sess_abc123 turnId: turn_abc123 invocationId: inv_abc123 +contextState: {} """; @@ -109,7 +115,8 @@ public void ToJsonProducesValidJson() "id": "context:inv_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "invocationId": "inv_abc123" + "invocationId": "inv_abc123", + "contextState": {} } """; @@ -129,6 +136,7 @@ public void ToYamlProducesValidYaml() sessionId: sess_abc123 turnId: turn_abc123 invocationId: inv_abc123 +contextState: {} """; diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelInvocationRequestConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelInvocationRequestConversionTests.cs index 85c997623..8b452c3cd 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelInvocationRequestConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelInvocationRequestConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelInvocationResponseConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelInvocationResponseConversionTests.cs index 4c23a488a..3499e3fb4 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelInvocationResponseConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelInvocationResponseConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelReconciliationStateConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelReconciliationStateConversionTests.cs index 820091a7b..4d4712f7d 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelReconciliationStateConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelReconciliationStateConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 @@ -14,6 +16,14 @@ public void LoadYamlInput() string yamlData = """ invocationId: inv_abc123 message: provider connection dropped after request was sent +request: + context: + id: "context:inv_abc123" + sessionId: sess_abc123 + turnId: turn_abc123 + invocationId: inv_abc123 + iteration: 1 + contextState: {} """; @@ -30,7 +40,17 @@ public void LoadJsonInput() string jsonData = """ { "invocationId": "inv_abc123", - "message": "provider connection dropped after request was sent" + "message": "provider connection dropped after request was sent", + "request": { + "context": { + "id": "context:inv_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "invocationId": "inv_abc123", + "iteration": 1, + "contextState": {} + } + } } """; @@ -47,7 +67,17 @@ public void RoundtripJson() string jsonData = """ { "invocationId": "inv_abc123", - "message": "provider connection dropped after request was sent" + "message": "provider connection dropped after request was sent", + "request": { + "context": { + "id": "context:inv_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "invocationId": "inv_abc123", + "iteration": 1, + "contextState": {} + } + } } """; @@ -70,6 +100,14 @@ public void RoundtripYaml() string yamlData = """ invocationId: inv_abc123 message: provider connection dropped after request was sent +request: + context: + id: "context:inv_abc123" + sessionId: sess_abc123 + turnId: turn_abc123 + invocationId: inv_abc123 + iteration: 1 + contextState: {} """; @@ -91,7 +129,17 @@ public void ToJsonProducesValidJson() string jsonData = """ { "invocationId": "inv_abc123", - "message": "provider connection dropped after request was sent" + "message": "provider connection dropped after request was sent", + "request": { + "context": { + "id": "context:inv_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "invocationId": "inv_abc123", + "iteration": 1, + "contextState": {} + } + } } """; @@ -109,6 +157,14 @@ public void ToYamlProducesValidYaml() string yamlData = """ invocationId: inv_abc123 message: provider connection dropped after request was sent +request: + context: + id: "context:inv_abc123" + sessionId: sess_abc123 + turnId: turn_abc123 + invocationId: inv_abc123 + iteration: 1 + contextState: {} """; diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelToolRequestConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelToolRequestConversionTests.cs index 55c261792..bdd2a11b9 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelToolRequestConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelToolRequestConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelToolResultConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelToolResultConversionTests.cs index 6a0154c60..b52eb511b 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelToolResultConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ModelToolResultConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayJournalRecordConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayJournalRecordConversionTests.cs index 85e4f0d69..0594b0bc5 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayJournalRecordConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayJournalRecordConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayMismatchConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayMismatchConversionTests.cs index 9c4929a5b..58c83ffb0 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayMismatchConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayMismatchConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayVerificationRequestConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayVerificationRequestConversionTests.cs index 68aa243f6..5c0dbcf09 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayVerificationRequestConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayVerificationRequestConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayVerificationResultConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayVerificationResultConversionTests.cs index d6b8bc874..5a39cd39e 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayVerificationResultConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ReplayVerificationResultConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ResumeContextConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ResumeContextConversionTests.cs index 35f5a4566..ef2bd82f3 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ResumeContextConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/ResumeContextConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 @@ -13,6 +15,14 @@ public void LoadYamlInput() { string yamlData = """ lastJournalSequence: 12 +checkpoint: + id: ckpt_abc123 + sessionId: sess_abc123 + turnId: turn_abc123 + runId: run_abc123 + iteration: 1 + lastSequence: 1 + contextState: {} """; @@ -27,7 +37,16 @@ public void LoadJsonInput() { string jsonData = """ { - "lastJournalSequence": 12 + "lastJournalSequence": 12, + "checkpoint": { + "id": "ckpt_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "runId": "run_abc123", + "iteration": 1, + "lastSequence": 1, + "contextState": {} + } } """; @@ -42,7 +61,16 @@ public void RoundtripJson() // Test that FromJson -> ToJson -> FromJson produces equivalent data string jsonData = """ { - "lastJournalSequence": 12 + "lastJournalSequence": 12, + "checkpoint": { + "id": "ckpt_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "runId": "run_abc123", + "iteration": 1, + "lastSequence": 1, + "contextState": {} + } } """; @@ -63,6 +91,14 @@ public void RoundtripYaml() // Test that FromYaml -> ToYaml -> FromYaml produces equivalent data string yamlData = """ lastJournalSequence: 12 +checkpoint: + id: ckpt_abc123 + sessionId: sess_abc123 + turnId: turn_abc123 + runId: run_abc123 + iteration: 1 + lastSequence: 1 + contextState: {} """; @@ -82,7 +118,16 @@ public void ToJsonProducesValidJson() { string jsonData = """ { - "lastJournalSequence": 12 + "lastJournalSequence": 12, + "checkpoint": { + "id": "ckpt_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "runId": "run_abc123", + "iteration": 1, + "lastSequence": 1, + "contextState": {} + } } """; @@ -99,6 +144,14 @@ public void ToYamlProducesValidYaml() { string yamlData = """ lastJournalSequence: 12 +checkpoint: + id: ckpt_abc123 + sessionId: sess_abc123 + turnId: turn_abc123 + runId: run_abc123 + iteration: 1 + lastSequence: 1 + contextState: {} """; diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/RetryPolicyRequestConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/RetryPolicyRequestConversionTests.cs index 874fa550b..80e292fcd 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/RetryPolicyRequestConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/RetryPolicyRequestConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/RunTurnRequestConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/RunTurnRequestConversionTests.cs index b53e9507d..29b879b8d 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/RunTurnRequestConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/RunTurnRequestConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/RunTurnResultConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/RunTurnResultConversionTests.cs index 5884332f7..75d1f8b6f 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/RunTurnResultConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/RunTurnResultConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnCommitConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnCommitConversionTests.cs index 3b4a699d5..2eb6cdc42 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnCommitConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnCommitConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 @@ -14,6 +16,7 @@ public void LoadYamlInput() string yamlData = """ sessionId: sess_abc123 turnId: turn_abc123 +contextState: {} """; @@ -30,7 +33,8 @@ public void LoadJsonInput() string jsonData = """ { "sessionId": "sess_abc123", - "turnId": "turn_abc123" + "turnId": "turn_abc123", + "contextState": {} } """; @@ -47,7 +51,8 @@ public void RoundtripJson() string jsonData = """ { "sessionId": "sess_abc123", - "turnId": "turn_abc123" + "turnId": "turn_abc123", + "contextState": {} } """; @@ -70,6 +75,7 @@ public void RoundtripYaml() string yamlData = """ sessionId: sess_abc123 turnId: turn_abc123 +contextState: {} """; @@ -91,7 +97,8 @@ public void ToJsonProducesValidJson() string jsonData = """ { "sessionId": "sess_abc123", - "turnId": "turn_abc123" + "turnId": "turn_abc123", + "contextState": {} } """; @@ -109,6 +116,7 @@ public void ToYamlProducesValidYaml() string yamlData = """ sessionId: sess_abc123 turnId: turn_abc123 +contextState: {} """; diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnEngineResultConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnEngineResultConversionTests.cs index 4d2142def..821da96d8 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnEngineResultConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnEngineResultConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnModelRequestConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnModelRequestConversionTests.cs index 2b25d24a0..a82723dbc 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnModelRequestConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnModelRequestConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnModelResponseConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnModelResponseConversionTests.cs index 5c1c6a38d..487d24dd1 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnModelResponseConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnModelResponseConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnOptionsConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnOptionsConversionTests.cs index 6df3c3d4e..1218b4c7d 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnOptionsConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/pipeline/TurnOptionsConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/streaming/StreamOptionsConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/streaming/StreamOptionsConversionTests.cs index bc96f118b..092484618 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/streaming/StreamOptionsConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/streaming/StreamOptionsConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/template/FormatConfigConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/template/FormatConfigConversionTests.cs index e2a2d0c47..386387f9c 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/template/FormatConfigConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/template/FormatConfigConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/template/ParserConfigConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/template/ParserConfigConversionTests.cs index 89322a250..4bff0012c 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/template/ParserConfigConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/template/ParserConfigConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/template/TemplateConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/template/TemplateConversionTests.cs index d2d8ac67c..df95bb3b9 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/template/TemplateConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/template/TemplateConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/tools/BindingConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/tools/BindingConversionTests.cs index 225c3d16f..eef2fc06b 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/tools/BindingConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/tools/BindingConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/tools/CustomToolConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/tools/CustomToolConversionTests.cs index 3c76ef2e2..51cda281e 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/tools/CustomToolConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/tools/CustomToolConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/tools/FunctionToolConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/tools/FunctionToolConversionTests.cs index a62d759af..d4f3d4519 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/tools/FunctionToolConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/tools/FunctionToolConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/tools/McpApprovalModeConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/tools/McpApprovalModeConversionTests.cs index 98a097691..12bfc560c 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/tools/McpApprovalModeConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/tools/McpApprovalModeConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/tools/McpToolConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/tools/McpToolConversionTests.cs index fc10a3a5e..e822b756f 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/tools/McpToolConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/tools/McpToolConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/tools/OpenApiToolConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/tools/OpenApiToolConversionTests.cs index b9df13c50..7c940e4fc 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/tools/OpenApiToolConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/tools/OpenApiToolConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/tools/PromptyToolConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/tools/PromptyToolConversionTests.cs index 99f20b42c..ee75aff17 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/tools/PromptyToolConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/tools/PromptyToolConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/tools/ToolContextConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/tools/ToolContextConversionTests.cs index 76a8d83d4..8e35682e5 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/tools/ToolContextConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/tools/ToolContextConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 @@ -14,6 +16,13 @@ public void LoadYamlInput() string yamlData = """ metadata: userId: user-123 +messages: + - role: user + parts: + - kind: text + value: Hello! + metadata: + source: user-input """; @@ -29,7 +38,21 @@ public void LoadJsonInput() { "metadata": { "userId": "user-123" - } + }, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "value": "Hello!" + } + ], + "metadata": { + "source": "user-input" + } + } + ] } """; @@ -45,7 +68,21 @@ public void RoundtripJson() { "metadata": { "userId": "user-123" - } + }, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "value": "Hello!" + } + ], + "metadata": { + "source": "user-input" + } + } + ] } """; @@ -66,6 +103,13 @@ public void RoundtripYaml() string yamlData = """ metadata: userId: user-123 +messages: + - role: user + parts: + - kind: text + value: Hello! + metadata: + source: user-input """; @@ -86,7 +130,21 @@ public void ToJsonProducesValidJson() { "metadata": { "userId": "user-123" - } + }, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "value": "Hello!" + } + ], + "metadata": { + "source": "user-input" + } + } + ] } """; @@ -104,6 +162,13 @@ public void ToYamlProducesValidYaml() string yamlData = """ metadata: userId: user-123 +messages: + - role: user + parts: + - kind: text + value: Hello! + metadata: + source: user-input """; diff --git a/runtime/csharp/Prompty.Core.Tests/Model/tools/ToolConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/tools/ToolConversionTests.cs index b08fec576..fe14fd7cf 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/tools/ToolConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/tools/ToolConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/tools/ToolDispatchResultConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/tools/ToolDispatchResultConversionTests.cs index 387b70184..50ba87f39 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/tools/ToolDispatchResultConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/tools/ToolDispatchResultConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/tracing/TraceFileConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/tracing/TraceFileConversionTests.cs index 66dac13ad..6edfe3741 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/tracing/TraceFileConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/tracing/TraceFileConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 @@ -14,6 +16,14 @@ public void LoadYamlInput() string yamlData = """ runtime: python version: 2.0.0 +trace: + name: prompty.core.pipeline.run + __time: + start: "2026-04-04T12:00:00Z" + end: "2026-04-04T12:00:01Z" + duration: 1000 + signature: prompty.core.pipeline.run + error: Connection refused """; @@ -30,7 +40,17 @@ public void LoadJsonInput() string jsonData = """ { "runtime": "python", - "version": "2.0.0" + "version": "2.0.0", + "trace": { + "name": "prompty.core.pipeline.run", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + }, + "signature": "prompty.core.pipeline.run", + "error": "Connection refused" + } } """; @@ -47,7 +67,17 @@ public void RoundtripJson() string jsonData = """ { "runtime": "python", - "version": "2.0.0" + "version": "2.0.0", + "trace": { + "name": "prompty.core.pipeline.run", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + }, + "signature": "prompty.core.pipeline.run", + "error": "Connection refused" + } } """; @@ -70,6 +100,14 @@ public void RoundtripYaml() string yamlData = """ runtime: python version: 2.0.0 +trace: + name: prompty.core.pipeline.run + __time: + start: "2026-04-04T12:00:00Z" + end: "2026-04-04T12:00:01Z" + duration: 1000 + signature: prompty.core.pipeline.run + error: Connection refused """; @@ -91,7 +129,17 @@ public void ToJsonProducesValidJson() string jsonData = """ { "runtime": "python", - "version": "2.0.0" + "version": "2.0.0", + "trace": { + "name": "prompty.core.pipeline.run", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + }, + "signature": "prompty.core.pipeline.run", + "error": "Connection refused" + } } """; @@ -109,6 +157,14 @@ public void ToYamlProducesValidYaml() string yamlData = """ runtime: python version: 2.0.0 +trace: + name: prompty.core.pipeline.run + __time: + start: "2026-04-04T12:00:00Z" + end: "2026-04-04T12:00:01Z" + duration: 1000 + signature: prompty.core.pipeline.run + error: Connection refused """; diff --git a/runtime/csharp/Prompty.Core.Tests/Model/tracing/TraceSpanConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/tracing/TraceSpanConversionTests.cs index 249576edb..d227fb394 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/tracing/TraceSpanConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/tracing/TraceSpanConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 @@ -15,6 +17,10 @@ public void LoadYamlInput() name: prompty.core.pipeline.run signature: prompty.core.pipeline.run error: Connection refused +__time: + start: "2026-04-04T12:00:00Z" + end: "2026-04-04T12:00:01Z" + duration: 1000 """; @@ -33,7 +39,12 @@ public void LoadJsonInput() { "name": "prompty.core.pipeline.run", "signature": "prompty.core.pipeline.run", - "error": "Connection refused" + "error": "Connection refused", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + } } """; @@ -52,7 +63,12 @@ public void RoundtripJson() { "name": "prompty.core.pipeline.run", "signature": "prompty.core.pipeline.run", - "error": "Connection refused" + "error": "Connection refused", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + } } """; @@ -77,6 +93,10 @@ public void RoundtripYaml() name: prompty.core.pipeline.run signature: prompty.core.pipeline.run error: Connection refused +__time: + start: "2026-04-04T12:00:00Z" + end: "2026-04-04T12:00:01Z" + duration: 1000 """; @@ -100,7 +120,12 @@ public void ToJsonProducesValidJson() { "name": "prompty.core.pipeline.run", "signature": "prompty.core.pipeline.run", - "error": "Connection refused" + "error": "Connection refused", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + } } """; @@ -119,6 +144,10 @@ public void ToYamlProducesValidYaml() name: prompty.core.pipeline.run signature: prompty.core.pipeline.run error: Connection refused +__time: + start: "2026-04-04T12:00:00Z" + end: "2026-04-04T12:00:01Z" + duration: 1000 """; diff --git a/runtime/csharp/Prompty.Core.Tests/Model/tracing/TraceTimeConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/tracing/TraceTimeConversionTests.cs index b851d49a4..253dc3616 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/tracing/TraceTimeConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/tracing/TraceTimeConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicImageBlockConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicImageBlockConversionTests.cs index 1784c1080..1c30893aa 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicImageBlockConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicImageBlockConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicImageSourceConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicImageSourceConversionTests.cs index 4a223505c..3a319d018 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicImageSourceConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicImageSourceConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicMessagesRequestConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicMessagesRequestConversionTests.cs index 3a823573a..e263bd4e4 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicMessagesRequestConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicMessagesRequestConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 @@ -20,6 +22,9 @@ public void LoadYamlInput() top_k: 40 stop_sequences: - "\n\nHuman:" +messages: + - role: user + content: [] """; @@ -47,6 +52,12 @@ public void LoadJsonInput() "top_k": 40, "stop_sequences": [ "\n\nHuman:" + ], + "messages": [ + { + "role": "user", + "content": [] + } ] } """; @@ -75,6 +86,12 @@ public void RoundtripJson() "top_k": 40, "stop_sequences": [ "\n\nHuman:" + ], + "messages": [ + { + "role": "user", + "content": [] + } ] } """; @@ -108,6 +125,9 @@ public void RoundtripYaml() top_k: 40 stop_sequences: - "\n\nHuman:" +messages: + - role: user + content: [] """; @@ -140,6 +160,12 @@ public void ToJsonProducesValidJson() "top_k": 40, "stop_sequences": [ "\n\nHuman:" + ], + "messages": [ + { + "role": "user", + "content": [] + } ] } """; @@ -164,6 +190,9 @@ public void ToYamlProducesValidYaml() top_k: 40 stop_sequences: - "\n\nHuman:" +messages: + - role: user + content: [] """; diff --git a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicMessagesResponseConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicMessagesResponseConversionTests.cs index 7b252b09a..4d6636fb0 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicMessagesResponseConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicMessagesResponseConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 @@ -15,6 +17,9 @@ public void LoadYamlInput() id: msg_01XFDUDYJgAACzvnptvVoYEL model: claude-sonnet-4-20250514 stop_reason: end_turn +usage: + input_tokens: 150 + output_tokens: 42 """; @@ -33,7 +38,11 @@ public void LoadJsonInput() { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn" + "stop_reason": "end_turn", + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } } """; @@ -52,7 +61,11 @@ public void RoundtripJson() { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn" + "stop_reason": "end_turn", + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } } """; @@ -77,6 +90,9 @@ public void RoundtripYaml() id: msg_01XFDUDYJgAACzvnptvVoYEL model: claude-sonnet-4-20250514 stop_reason: end_turn +usage: + input_tokens: 150 + output_tokens: 42 """; @@ -100,7 +116,11 @@ public void ToJsonProducesValidJson() { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn" + "stop_reason": "end_turn", + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } } """; @@ -119,6 +139,9 @@ public void ToYamlProducesValidYaml() id: msg_01XFDUDYJgAACzvnptvVoYEL model: claude-sonnet-4-20250514 stop_reason: end_turn +usage: + input_tokens: 150 + output_tokens: 42 """; diff --git a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicTextBlockConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicTextBlockConversionTests.cs index ca4336d40..106c7bc8b 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicTextBlockConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicTextBlockConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicToolDefinitionConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicToolDefinitionConversionTests.cs index 5851652f1..f1eb93ed2 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicToolDefinitionConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicToolDefinitionConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicToolResultBlockConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicToolResultBlockConversionTests.cs index c048595ae..22b03cfbe 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicToolResultBlockConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicToolResultBlockConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicToolUseBlockConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicToolUseBlockConversionTests.cs index 9340190ce..60865843d 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicToolUseBlockConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicToolUseBlockConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicUsageConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicUsageConversionTests.cs index 66bced5ac..d461350e3 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicUsageConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicUsageConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicWireMessageConversionTests.cs b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicWireMessageConversionTests.cs index 2999434b0..80557cedc 100644 --- a/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicWireMessageConversionTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/Model/wire/AnthropicWireMessageConversionTests.cs @@ -1,4 +1,6 @@ // +#nullable enable + using Xunit; #pragma warning disable IDE0130 diff --git a/runtime/csharp/Prompty.Core.Tests/NamedCollectionVectorTests.cs b/runtime/csharp/Prompty.Core.Tests/NamedCollectionVectorTests.cs new file mode 100644 index 000000000..e14e94ec1 --- /dev/null +++ b/runtime/csharp/Prompty.Core.Tests/NamedCollectionVectorTests.cs @@ -0,0 +1,285 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Prompty.Core.Tests; + +/// +/// Named-collection load/save/reload contracts backed by the shared model vectors. +/// Ported from the Rust reference suite so the contract is executed against the +/// C# emitted models rather than assumed to hold. +/// +public class NamedCollectionVectorTests +{ + private static readonly string VectorsPath = FindVectorsPath(); + + public static TheoryData RoundtripVectorNames() => VectorNames("load-save-reload"); + + public static TheoryData RejectionVectorNames() => VectorNames("load-error"); + + [Theory] + [MemberData(nameof(RoundtripVectorNames))] + public void NamedCollectionVectors_RoundtripThroughLoadSaveReload(string vectorName) + { + var vector = FindVector(vectorName); + var expected = vector.GetProperty("expected"); + var collectionPath = vector.GetProperty("collectionPath").GetString()!; + var input = JsonElementToDict(vector.GetProperty("input")); + + var loaded = Prompty.Load(input); + var saved = loaded.Save(); + AssertNamedCollection(vectorName, ExtractCollection(vectorName, saved, collectionPath), expected); + + var reloaded = Prompty.Load(saved); + var resaved = reloaded.Save(); + AssertNamedCollection(vectorName, ExtractCollection(vectorName, resaved, collectionPath), expected); + } + + [Theory] + [MemberData(nameof(RejectionVectorNames))] + public void NamedCollectionVectors_RejectInvalidEntries(string vectorName) + { + var vector = FindVector(vectorName); + var expected = vector.GetProperty("expected"); + var input = JsonElementToDict(vector.GetProperty("input")); + + var thrown = Record.Exception(() => Prompty.Load(input)); + Assert.True( + thrown is not null, + $"[{vectorName}] expected rejection at {expected.GetProperty("path").GetString()} " + + $"(category {expected.GetProperty("valueCategory").GetString()}), but load succeeded."); + } + + private static JsonNode ExtractCollection( + string vectorName, + Dictionary saved, + string collectionPath) + { + var savedNode = JsonNode.Parse(JsonSerializer.Serialize(saved))!.AsObject(); + Assert.True( + savedNode.TryGetPropertyValue(collectionPath, out var collection) && collection is not null, + $"[{vectorName}] missing collection \"{collectionPath}\" after save."); + return collection!; + } + + /// + /// Normalizes either named-collection wire form into a comparable list of + /// entries carrying an explicit name. + /// + private static List SemanticEntries(string vectorName, JsonNode collection) + { + if (collection is JsonArray array) + { + var entries = new List(array.Count); + for (var index = 0; index < array.Count; index++) + { + var entry = array[index] as JsonObject; + Assert.True(entry is not null, $"[{vectorName}] array-form entry {index} must be an object."); + var clone = (JsonObject)entry!.DeepClone(); + if (!clone.ContainsKey("name")) + clone["name"] = ""; + entries.Add(clone); + } + + return entries; + } + + var collectionObject = collection as JsonObject; + Assert.True(collectionObject is not null, $"[{vectorName}] named collection must be an array or object."); + + return collectionObject! + .Select(pair => pair.Key) + .OrderBy(name => name, StringComparer.Ordinal) + .Select(name => + { + var entry = collectionObject[name] as JsonObject; + Assert.True(entry is not null, $"[{vectorName}] object-form entry \"{name}\" must be an object."); + var clone = (JsonObject)entry!.DeepClone(); + clone["name"] = name; + return clone; + }) + .ToList(); + } + + /// + /// Every field the vector declares must be present and equal. Fields the + /// vector does not mention are ignored. + /// + private static void AssertSubset(JsonNode? actual, JsonNode? expected, string path) + { + if (expected is not JsonObject expectedObject) + { + Assert.True( + JsonNode.DeepEquals(actual, expected), + $"{path}: expected {expected?.ToJsonString() ?? "null"}, got {actual?.ToJsonString() ?? "null"}"); + return; + } + + var actualObject = actual as JsonObject; + Assert.True(actualObject is not null, $"{path}: expected an object, got {actual?.ToJsonString() ?? "null"}"); + + foreach (var (key, expectedValue) in expectedObject) + { + Assert.True(actualObject!.ContainsKey(key), $"{path}: missing field \"{key}\""); + AssertSubset(actualObject[key], expectedValue, $"{path}.{key}"); + } + } + + private static void AssertNamedCollection(string vectorName, JsonNode collection, JsonElement expected) + { + var expectedFormat = expected.GetProperty("collectionFormat").GetString()!; + var actualFormat = collection is JsonArray ? "array" : "object"; + Assert.True( + actualFormat == expectedFormat, + $"[{vectorName}] expected {expectedFormat} collection form, got {actualFormat}."); + + // wireEntries assert on the raw saved payload: each is {index, absentFields}, + // requiring that the entry at that position never materializes a synthetic field. + if (expected.TryGetProperty("wireEntries", out var wireEntries)) + { + var rawEntries = (JsonArray)collection; + foreach (var assertion in wireEntries.EnumerateArray()) + { + var index = assertion.GetProperty("index").GetInt32(); + Assert.True( + index >= 0 && index < rawEntries.Count, + $"[{vectorName}] wire entry index {index} out of range ({rawEntries.Count} entries)."); + + var entry = rawEntries[index] as JsonObject; + Assert.True(entry is not null, $"[{vectorName}] wire entry {index} must be an object."); + + if (!assertion.TryGetProperty("absentFields", out var absentFields)) + continue; + + foreach (var field in absentFields.EnumerateArray()) + { + var name = field.GetString()!; + Assert.True( + !entry!.ContainsKey(name), + $"[{vectorName}] wire entry {index} unexpectedly materialized field \"{name}\" " + + $"as {entry[name]?.ToJsonString() ?? "null"}."); + } + } + } + + var actualEntries = SemanticEntries(vectorName, collection); + var expectedEntries = expected.GetProperty("entries").EnumerateArray().ToList(); + Assert.True( + actualEntries.Count == expectedEntries.Count, + $"[{vectorName}] named collection entry count changed: " + + $"expected {expectedEntries.Count}, got {actualEntries.Count}."); + + if (expected.TryGetProperty("absentEntryFields", out var absentEntryFields)) + { + foreach (var entry in actualEntries) + { + foreach (var field in absentEntryFields.EnumerateArray()) + { + var name = field.GetString()!; + Assert.True( + !entry.ContainsKey(name), + $"[{vectorName}] entry {entry["name"]?.ToJsonString()} unexpectedly populated " + + $"field \"{name}\" with {entry[name]?.ToJsonString() ?? "null"}."); + } + } + } + + if (expected.TryGetProperty("preserveOrder", out var preserveOrder) && preserveOrder.GetBoolean()) + { + for (var index = 0; index < expectedEntries.Count; index++) + { + AssertSubset( + actualEntries[index], + JsonNode.Parse(expectedEntries[index].GetRawText()), + $"{vectorName}.entries[{index}]"); + } + + return; + } + + var actualByName = actualEntries.ToDictionary(entry => entry["name"]!.GetValue()); + foreach (var expectedEntry in expectedEntries) + { + var name = expectedEntry.GetProperty("name").GetString()!; + Assert.True(actualByName.ContainsKey(name), $"[{vectorName}] missing named entry \"{name}\"."); + AssertSubset( + actualByName[name], + JsonNode.Parse(expectedEntry.GetRawText()), + $"{vectorName}.entries.{name}"); + } + } + + /// + /// Recursively materializes JSON into native dictionaries and lists. The + /// emitted loaders traverse those; raw JsonElement values are not walked. + /// + private static Dictionary JsonElementToDict(JsonElement element) + { + var dictionary = new Dictionary(); + if (element.ValueKind != JsonValueKind.Object) + return dictionary; + + foreach (var property in element.EnumerateObject()) + dictionary[property.Name] = JsonElementToObject(property.Value); + + return dictionary; + } + + private static object? JsonElementToObject(JsonElement element) => element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt64(out var l) + ? (l == (int)l ? (object)(int)l : l) + : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(), + JsonValueKind.Object => JsonElementToDict(element), + _ => element.GetRawText(), + }; + + private static JsonElement FindVector(string vectorName) + { + using var document = JsonDocument.Parse(File.ReadAllText(VectorsPath)); + return document.RootElement + .GetProperty("vectors") + .EnumerateArray() + .Single(candidate => candidate.GetProperty("name").GetString() == vectorName) + .Clone(); + } + + private static TheoryData VectorNames(string operation) + { + using var document = JsonDocument.Parse(File.ReadAllText(VectorsPath)); + var data = new TheoryData(); + foreach (var vector in document.RootElement.GetProperty("vectors").EnumerateArray()) + { + if (vector.GetProperty("operation").GetString() == operation) + data.Add(vector.GetProperty("name").GetString()!); + } + + return data; + } + + private static string FindVectorsPath() + { + var directory = AppContext.BaseDirectory; + for (var i = 0; i < 10; i++) + { + var candidate = Path.Combine( + directory, + "spec", + "vectors", + "model", + "named_collection_vectors.json"); + if (File.Exists(candidate)) + return candidate; + + directory = Path.GetDirectoryName(directory) ?? directory; + } + + throw new FileNotFoundException("Could not locate the shared named collection vectors."); + } +} diff --git a/runtime/csharp/Prompty.Core.Tests/PipelineTests.cs b/runtime/csharp/Prompty.Core.Tests/PipelineTests.cs index 4c111edcb..332e79d79 100644 --- a/runtime/csharp/Prompty.Core.Tests/PipelineTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/PipelineTests.cs @@ -335,6 +335,22 @@ public async Task ExecuteAsync_UsesProvider() Assert.Equal("mock-response", response); } + [Fact] + public async Task ExecuteAsync_ForwardsCancellationToken() + { + var agent = CreateAgent(); + var executor = new MockExecutor(); + InvokerRegistry.RegisterExecutor("openai", executor); + using var cancellation = new CancellationTokenSource(); + + await Pipeline.ExecuteAsync( + agent, + [new Message { Parts = [new TextPart { Value = "Hi" }] }], + cancellation.Token); + + Assert.Equal(cancellation.Token, executor.LastCancellationToken); + } + [Fact] public async Task ProcessAsync_UsesProvider() { @@ -601,8 +617,16 @@ public Task> ParseAsync(Prompty agent, string rendered, Dictionary internal class MockExecutor : IExecutor { - public Task ExecuteAsync(Prompty agent, List messages) - => Task.FromResult("mock-response"); + public CancellationToken LastCancellationToken { get; private set; } + + public Task ExecuteAsync( + Prompty agent, + List messages, + CancellationToken cancellationToken = default) + { + LastCancellationToken = cancellationToken; + return Task.FromResult("mock-response"); + } public List FormatToolMessages(object rawResponse, List toolCalls, List toolResults, string? textContent = null) { @@ -629,7 +653,10 @@ internal class ToolCallingExecutor : IExecutor { private int _callCount; - public Task ExecuteAsync(Prompty agent, List messages) + public Task ExecuteAsync( + Prompty agent, + List messages, + CancellationToken cancellationToken = default) { _callCount++; if (_callCount == 1) diff --git a/runtime/csharp/Prompty.Core.Tests/Prompts/tools.prompty b/runtime/csharp/Prompty.Core.Tests/Prompts/tools.prompty index bb88218a1..07cfb0b0a 100644 --- a/runtime/csharp/Prompty.Core.Tests/Prompts/tools.prompty +++ b/runtime/csharp/Prompty.Core.Tests/Prompts/tools.prompty @@ -8,10 +8,9 @@ tools: kind: function description: Get the current weather parameters: - properties: - - name: location - kind: string - description: City and state + - name: location + kind: string + description: City and state --- system: You are a helpful assistant with access to tools. diff --git a/runtime/csharp/Prompty.Core.Tests/PropertyScalarCoercionVectorTests.cs b/runtime/csharp/Prompty.Core.Tests/PropertyScalarCoercionVectorTests.cs new file mode 100644 index 000000000..ebe2e480d --- /dev/null +++ b/runtime/csharp/Prompty.Core.Tests/PropertyScalarCoercionVectorTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Prompty.Core; + +namespace Prompty.Core.Tests; + +public class PropertyScalarCoercionVectorTests +{ + private static readonly string VectorsPath = FindVectorsPath(); + + [Fact] + public void AllPrimitivePropertyScalarsCoerceAtomically() + { + using var document = JsonDocument.Parse(File.ReadAllText(VectorsPath)); + var vectors = document.RootElement.GetProperty("vectors").EnumerateArray().ToArray(); + Assert.Single(vectors); + + var vector = vectors[0]; + Assert.Equal("all_primitive_property_scalars_coerce_atomically", vector.GetProperty("name").GetString()); + Assert.Equal("load", vector.GetProperty("operation").GetString()); + + var cases = vector.GetProperty("cases").EnumerateArray().ToArray(); + Assert.Equal( + ["string", "integer", "float", "boolean"], + cases.Select(candidate => candidate.GetProperty("name").GetString()!).ToArray()); + + foreach (var scalarCase in cases) + { + var name = scalarCase.GetProperty("name").GetString(); + var expected = scalarCase.GetProperty("expected"); + var loaded = Property.FromJson(scalarCase.GetProperty("input").GetRawText()); + + Assert.Equal(expected.GetProperty("kind").GetString(), loaded.Kind); + Assert.True( + JsonNode.DeepEquals( + JsonNode.Parse(expected.GetProperty("example").GetRawText()), + JsonNode.Parse(JsonSerializer.Serialize(loaded.Example))), + $"[{name}] changed or dropped the Property example."); + } + } + + private static string FindVectorsPath() + { + var directory = AppContext.BaseDirectory; + for (var i = 0; i < 10; i++) + { + var candidate = Path.Combine( + directory, + "spec", + "vectors", + "model", + "property_scalar_coercion_vectors.json"); + if (File.Exists(candidate)) + return candidate; + + directory = Path.GetDirectoryName(directory) ?? directory; + } + + throw new FileNotFoundException("Could not locate the shared Property scalar coercion vectors."); + } +} diff --git a/runtime/csharp/Prompty.Core.Tests/RecordUnknownNullabilitySignatureTests.cs b/runtime/csharp/Prompty.Core.Tests/RecordUnknownNullabilitySignatureTests.cs new file mode 100644 index 000000000..6594b74e1 --- /dev/null +++ b/runtime/csharp/Prompty.Core.Tests/RecordUnknownNullabilitySignatureTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Reflection; +using Prompty.Core; + +namespace Prompty.Core.Tests; + +public class RecordUnknownNullabilitySignatureTests +{ + [Fact] + public void RecordUnknownProperties_ExposeCanonicalNullableValueSignatures() + { + var cases = new (Type Model, string Property, NullabilityState Presence)[] + { + (typeof(Message), nameof(Message.Metadata), NullabilityState.NotNull), + (typeof(Prompty), nameof(Prompty.Metadata), NullabilityState.Nullable), + (typeof(ModelInfo), nameof(ModelInfo.AdditionalProperties), NullabilityState.Nullable), + (typeof(TurnModelRequest), nameof(TurnModelRequest.Inputs), NullabilityState.Nullable), + (typeof(RunTurnRequest), nameof(RunTurnRequest.Inputs), NullabilityState.Nullable), + (typeof(TurnModelResponse), nameof(TurnModelResponse.CheckpointState), NullabilityState.Nullable), + (typeof(HostToolRequest), nameof(HostToolRequest.Arguments), NullabilityState.Nullable), + (typeof(TurnEvent), nameof(TurnEvent.Payload), NullabilityState.NotNull), + (typeof(SessionEvent), nameof(SessionEvent.Payload), NullabilityState.NotNull), + }; + var context = new NullabilityInfoContext(); + + foreach (var (model, propertyName, expectedPresence) in cases) + { + var property = model.GetProperty(propertyName) + ?? throw new InvalidOperationException($"{model.Name}.{propertyName} does not exist."); + Assert.Equal(typeof(IDictionary<,>), property.PropertyType.GetGenericTypeDefinition()); + + var nullability = context.Create(property); + Assert.Equal(expectedPresence, nullability.ReadState); + Assert.Equal(typeof(string), nullability.GenericTypeArguments[0].Type); + Assert.Equal(NullabilityState.NotNull, nullability.GenericTypeArguments[0].ReadState); + Assert.Equal(typeof(object), nullability.GenericTypeArguments[1].Type); + Assert.Equal(NullabilityState.Nullable, nullability.GenericTypeArguments[1].ReadState); + } + } +} diff --git a/runtime/csharp/Prompty.Core.Tests/RecordUnknownNullabilityVectorTests.cs b/runtime/csharp/Prompty.Core.Tests/RecordUnknownNullabilityVectorTests.cs new file mode 100644 index 000000000..bdb54f058 --- /dev/null +++ b/runtime/csharp/Prompty.Core.Tests/RecordUnknownNullabilityVectorTests.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Prompty.Core; + +namespace Prompty.Core.Tests; + +public class RecordUnknownNullabilityVectorTests +{ + private static readonly string VectorsPath = FindVectorsPath(); + + [Fact] + public void RecordUnknownNullabilityVectors_PreserveExplicitNullValues() + { + using var document = JsonDocument.Parse(File.ReadAllText(VectorsPath)); + var vectors = document.RootElement.GetProperty("vectors").EnumerateArray().ToArray(); + Assert.Equal(9, vectors.Length); + var expectedCoverage = new HashSet(StringComparer.Ordinal) + { + "Message:metadata", + "Prompty:metadata", + "ModelInfo:additionalProperties", + "TurnModelRequest:inputs", + "RunTurnRequest:inputs", + "TurnModelResponse:checkpointState", + "HostToolRequest:arguments", + "TurnEvent:payload", + "SessionEvent:payload", + }; + var actualCoverage = vectors + .Select(vector => $"{vector.GetProperty("model").GetString()}:{vector.GetProperty("fieldPath").GetString()}") + .ToHashSet(StringComparer.Ordinal); + Assert.True( + expectedCoverage.SetEquals(actualCoverage), + $"Record vector coverage changed.\nExpected: {string.Join(", ", expectedCoverage)}\n" + + $"Actual: {string.Join(", ", actualCoverage)}"); + + foreach (var vector in vectors) + { + var name = vector.GetProperty("name").GetString()!; + Assert.Equal("load-save-reload", vector.GetProperty("operation").GetString()); + + var model = vector.GetProperty("model").GetString()!; + var fieldPath = vector.GetProperty("fieldPath").GetString()!; + var resaved = Roundtrip(model, vector.GetProperty("input").GetRawText()); + var actual = resaved.GetProperty(fieldPath); + var expected = vector.GetProperty("expected"); + + Assert.True( + actual.TryGetProperty("direct", out var direct) && direct.ValueKind == JsonValueKind.Null, + $"[{name}] direct null-valued key was lost or changed."); + Assert.True( + JsonNode.DeepEquals(JsonNode.Parse(expected.GetRawText()), JsonNode.Parse(actual.GetRawText())), + $"[{name}] load/save/reload changed null-valued record entries.\nExpected: {expected}\nActual: {actual}"); + } + } + + private static JsonElement Roundtrip(string model, string input) + { + var resaved = model switch + { + "Message" => Roundtrip(input, json => Message.FromJson(json), value => value.ToJson()), + "Prompty" => Roundtrip(input, json => Prompty.FromJson(json), value => value.ToJson()), + "ModelInfo" => Roundtrip(input, json => ModelInfo.FromJson(json), value => value.ToJson()), + "TurnModelRequest" => Roundtrip( + input, + json => TurnModelRequest.FromJson(json), + value => value.ToJson()), + "RunTurnRequest" => Roundtrip( + input, + json => RunTurnRequest.FromJson(json), + value => value.ToJson()), + "TurnModelResponse" => Roundtrip( + input, + json => TurnModelResponse.FromJson(json), + value => value.ToJson()), + "HostToolRequest" => Roundtrip( + input, + json => HostToolRequest.FromJson(json), + value => value.ToJson()), + "TurnEvent" => Roundtrip(input, json => TurnEvent.FromJson(json), value => value.ToJson()), + "SessionEvent" => Roundtrip( + input, + json => SessionEvent.FromJson(json), + value => value.ToJson()), + _ => throw new InvalidOperationException($"Unsupported vector model '{model}'."), + }; + using var document = JsonDocument.Parse(resaved); + return document.RootElement.Clone(); + } + + private static string Roundtrip(string input, Func load, Func save) + { + var loaded = load(input); + var saved = save(loaded); + var reloaded = load(saved); + return save(reloaded); + } + + private static string FindVectorsPath() + { + var directory = AppContext.BaseDirectory; + for (var i = 0; i < 10; i++) + { + var candidate = Path.Combine( + directory, + "spec", + "vectors", + "model", + "record_unknown_nullability_vectors.json"); + if (File.Exists(candidate)) + return candidate; + + directory = Path.GetDirectoryName(directory) ?? directory; + } + + throw new FileNotFoundException("Could not locate the shared Record nullability vectors."); + } +} diff --git a/runtime/csharp/Prompty.Core.Tests/ResilienceTests.cs b/runtime/csharp/Prompty.Core.Tests/ResilienceTests.cs index a972bcfb3..ea36025d6 100644 --- a/runtime/csharp/Prompty.Core.Tests/ResilienceTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/ResilienceTests.cs @@ -22,7 +22,10 @@ namespace Prompty.Core.Tests; public void EnqueueResponse(object response) => _responses.Enqueue(response); public void EnqueueException(Exception ex) => _exceptions.Enqueue(ex); - public Task ExecuteAsync(Prompty agent, List messages) + public Task ExecuteAsync( + Prompty agent, + List messages, + CancellationToken cancellationToken = default) { Calls.Add(new List(messages)); diff --git a/runtime/csharp/Prompty.Core.Tests/SpecVectorTests.cs b/runtime/csharp/Prompty.Core.Tests/SpecVectorTests.cs index 91519b0a8..aa278a3eb 100644 --- a/runtime/csharp/Prompty.Core.Tests/SpecVectorTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/SpecVectorTests.cs @@ -460,6 +460,54 @@ private static List CompareAgentToExpected(Prompty agent, JsonElement ex } } + if (expected.TryGetProperty("tools", out var toolsEl) && toolsEl.ValueKind == JsonValueKind.Array) + { + var expectedTools = toolsEl.EnumerateArray().ToList(); + var actualTools = agent.Tools ?? []; + if (actualTools.Count != expectedTools.Count) + { + errors.Add($"tools: expected {expectedTools.Count}, got {actualTools.Count}"); + } + + for (var i = 0; i < Math.Min(actualTools.Count, expectedTools.Count); i++) + { + var expectedTool = expectedTools[i]; + if (!expectedTool.TryGetProperty("bindings", out var bindingsEl) || + bindingsEl.ValueKind != JsonValueKind.Object) + { + continue; + } + + var expectedBindings = bindingsEl.EnumerateObject().ToList(); + var actualBindings = actualTools[i].Bindings ?? []; + if (actualBindings.Count != expectedBindings.Count) + { + errors.Add( + $"tools[{i}].bindings: expected {expectedBindings.Count}, got {actualBindings.Count}"); + } + + foreach (var expectedBinding in expectedBindings) + { + var actualBinding = actualBindings.FirstOrDefault(binding => binding.Name == expectedBinding.Name); + if (actualBinding is null) + { + errors.Add($"tools[{i}].bindings: missing binding '{expectedBinding.Name}'"); + continue; + } + + var expectedInput = expectedBinding.Value.ValueKind == JsonValueKind.Object + ? expectedBinding.Value.GetProperty("input").GetString() + : expectedBinding.Value.GetString(); + if (actualBinding.Input != expectedInput) + { + errors.Add( + $"tools[{i}].bindings.{expectedBinding.Name}.input: " + + $"expected '{expectedInput}', got '{actualBinding.Input}'"); + } + } + } + } + return errors; } diff --git a/runtime/csharp/Prompty.Core.Tests/StructuredOutputPipelineTests.cs b/runtime/csharp/Prompty.Core.Tests/StructuredOutputPipelineTests.cs index 005771327..5430cff48 100644 --- a/runtime/csharp/Prompty.Core.Tests/StructuredOutputPipelineTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/StructuredOutputPipelineTests.cs @@ -261,7 +261,10 @@ internal class RawJsonExecutor : IExecutor public RawJsonExecutor(string rawJson) => _rawJson = rawJson; - public Task ExecuteAsync(Prompty agent, List messages) + public Task ExecuteAsync( + Prompty agent, + List messages, + CancellationToken cancellationToken = default) => Task.FromResult(_rawJson); public List FormatToolMessages(object rawResponse, List toolCalls, List toolResults, string? textContent = null) diff --git a/runtime/csharp/Prompty.Core.Tests/TracingTests.cs b/runtime/csharp/Prompty.Core.Tests/TracingTests.cs index 7c588e007..e4bae9dcb 100644 --- a/runtime/csharp/Prompty.Core.Tests/TracingTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/TracingTests.cs @@ -577,7 +577,10 @@ public Task> ParseAsync(Prompty agent, string rendered, Dictionary private class MockExecutor(object response) : IExecutor { - public Task ExecuteAsync(Prompty agent, List messages) + public Task ExecuteAsync( + Prompty agent, + List messages, + CancellationToken cancellationToken = default) => Task.FromResult(response); public List FormatToolMessages(object rawResponse, List toolCalls, List toolResults, string? textContent = null) diff --git a/runtime/csharp/Prompty.Core/Model/Context.cs b/runtime/csharp/Prompty.Core/Model/Context.cs index 6182b68e8..023460b9d 100644 --- a/runtime/csharp/Prompty.Core/Model/Context.cs +++ b/runtime/csharp/Prompty.Core/Model/Context.cs @@ -1,5 +1,7 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; using YamlDotNet.Serialization; @@ -24,6 +26,26 @@ public class LoadContext /// public Func? PostProcess { get; set; } + /// Current schema path used for load diagnostics. + public string Path { get; init; } = ""; + + /// Create a child context for a nested schema field. + public LoadContext At(string segment) => new() + { + PreProcess = PreProcess, + PostProcess = PostProcess, + Path = string.IsNullOrEmpty(Path) ? segment : $"{Path}.{segment}", + }; + + /// Create a child context for an array element. Rendered with bracket + /// notation so a diagnostic identifies which element failed. + public LoadContext AtIndex(int index) => new() + { + PreProcess = PreProcess, + PostProcess = PostProcess, + Path = $"{Path}[{index}]", + }; + /// /// Apply pre-processing to input data if a PreProcess callback is set. /// diff --git a/runtime/csharp/Prompty.Core/Model/Utils.cs b/runtime/csharp/Prompty.Core/Model/Utils.cs index 5d2e7d073..0adcb9ae1 100644 --- a/runtime/csharp/Prompty.Core/Model/Utils.cs +++ b/runtime/csharp/Prompty.Core/Model/Utils.cs @@ -1,5 +1,7 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Collections; using System.Reflection; using System.Text.Json; diff --git a/runtime/csharp/Prompty.Core/Model/agent/GuardrailResult.cs b/runtime/csharp/Prompty.Core/Model/agent/GuardrailResult.cs index 6b1157764..fda55a037 100644 --- a/runtime/csharp/Prompty.Core/Model/agent/GuardrailResult.cs +++ b/runtime/csharp/Prompty.Core/Model/agent/GuardrailResult.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -57,6 +60,7 @@ public GuardrailResult() /// The loaded GuardrailResult instance. public static GuardrailResult Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -74,7 +78,7 @@ public static GuardrailResult Load(Dictionary data, LoadContext if (data.TryGetValue("reason", out var reasonValue) && reasonValue is not null) { - instance.Reason = reasonValue?.ToString()!; + instance.Reason = reasonValue.ToString()!; } if (data.TryGetValue("rewrite", out var rewriteValue) && rewriteValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/agent/Prompty.cs b/runtime/csharp/Prompty.Core/Model/agent/Prompty.cs index e51b543dd..c298662d5 100644 --- a/runtime/csharp/Prompty.Core/Model/agent/Prompty.cs +++ b/runtime/csharp/Prompty.Core/Model/agent/Prompty.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -60,9 +63,9 @@ public Prompty() public string? Description { get; set; } /// - /// Additional metadata including authors, tags, and other arbitrary properties + /// Additional metadata including authors, tags, and other arbitrary properties. Values may be explicit null. /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } /// /// Input parameters that participate in template rendering @@ -77,12 +80,12 @@ public Prompty() /// /// AI model configuration /// - public Model Model { get; set; } + public Model? Model { get; set; } /// /// Tools available for extended functionality /// - public IList? Tools { get; set; } + public IList? Tools { get; set; } = []; /// /// Template configuration for prompt rendering @@ -106,6 +109,7 @@ public Prompty() /// The loaded Prompty instance. public static Prompty Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -118,17 +122,17 @@ public static Prompty Load(Dictionary data, LoadContext? contex if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("displayName", out var displayNameValue) && displayNameValue is not null) { - instance.DisplayName = displayNameValue?.ToString()!; + instance.DisplayName = displayNameValue.ToString()!; } if (data.TryGetValue("description", out var descriptionValue) && descriptionValue is not null) { - instance.Description = descriptionValue?.ToString()!; + instance.Description = descriptionValue.ToString()!; } if (data.TryGetValue("metadata", out var metadataValue) && metadataValue is not null) @@ -138,32 +142,32 @@ public static Prompty Load(Dictionary data, LoadContext? contex if (data.TryGetValue("inputs", out var inputsValue) && inputsValue is not null) { - instance.Inputs = LoadInputs(inputsValue, context); + instance.Inputs = LoadInputs(inputsValue, context!.At("inputs")); } if (data.TryGetValue("outputs", out var outputsValue) && outputsValue is not null) { - instance.Outputs = LoadOutputs(outputsValue, context); + instance.Outputs = LoadOutputs(outputsValue, context!.At("outputs")); } if (data.TryGetValue("model", out var modelValue) && modelValue is not null) { - instance.Model = Model.Load(modelValue.GetDictionary(Model.ShorthandProperty), context); + instance.Model = Model.Load(modelValue.GetDictionary(Model.ShorthandProperty), context!.At("model")); } if (data.TryGetValue("tools", out var toolsValue) && toolsValue is not null) { - instance.Tools = LoadTools(toolsValue, context); + instance.Tools = LoadTools(toolsValue, context!.At("tools")); } if (data.TryGetValue("template", out var templateValue) && templateValue is not null) { - instance.Template = Template.Load(templateValue.GetDictionary(Template.ShorthandProperty), context); + instance.Template = Template.Load(templateValue.GetDictionary(Template.ShorthandProperty), context!.At("template")); } if (data.TryGetValue("instructions", out var instructionsValue) && instructionsValue is not null) { - instance.Instructions = instructionsValue?.ToString()!; + instance.Instructions = instructionsValue.ToString()!; } if (context is not null) @@ -181,46 +185,65 @@ public static IList LoadInputs(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'inputs' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "inputs" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'inputs' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Property.Load(itemDict, context)); + result.Add(Property.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, - ["example"] = kvp.Value + ["default"] = kvp.Value }; - result.Add(Property.Load(newDict, context)); + if (kvp.Value is int or long or short or byte) + { + newDict["kind"] = "integer"; + } + else if (kvp.Value is double or float or decimal) + { + newDict["kind"] = "float"; + } + else if (kvp.Value is string) + { + newDict["kind"] = "string"; + } + else if (kvp.Value is bool) + { + newDict["kind"] = "boolean"; + } + result.Add(Property.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Property.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Property.Load(itemDict, context)); + result.Add(Property.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -235,46 +258,65 @@ public static IList LoadOutputs(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'outputs' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "outputs" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'outputs' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Property.Load(itemDict, context)); + result.Add(Property.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, - ["example"] = kvp.Value + ["default"] = kvp.Value }; - result.Add(Property.Load(newDict, context)); + if (kvp.Value is int or long or short or byte) + { + newDict["kind"] = "integer"; + } + else if (kvp.Value is double or float or decimal) + { + newDict["kind"] = "float"; + } + else if (kvp.Value is string) + { + newDict["kind"] = "string"; + } + else if (kvp.Value is bool) + { + newDict["kind"] = "boolean"; + } + result.Add(Property.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Property.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Property.Load(itemDict, context)); + result.Add(Property.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -289,46 +331,49 @@ public static IList LoadTools(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'tools' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "tools" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'tools' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Tool.Load(itemDict, context)); + result.Add(Tool.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["kind"] = kvp.Value }; - result.Add(Tool.Load(newDict, context)); + result.Add(Tool.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Tool.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Tool.Load(itemDict, context)); + result.Add(Tool.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -390,7 +435,10 @@ public static IList LoadTools(object data, LoadContext? context) } - result["model"] = obj.Model?.Save(context); + if (obj.Model is not null) + { + result["model"] = obj.Model?.Save(context); + } if (obj.Tools is not null) @@ -428,36 +476,42 @@ public static object SaveInputs(IList items, SaveContext? context) context ??= new SaveContext(); + var serialized = items.Select(item => new Dictionary(item.Save(context))).ToList(); + foreach (var itemData in serialized) + { + if (itemData.TryGetValue("name", out var nameValue) && nameValue is string { Length: 0 }) itemData.Remove("name"); + } + if (context.CollectionFormat == "array") { - return items.Select(item => item.Save(context)).ToList(); + return serialized; + } + + var names = new HashSet(StringComparer.Ordinal); + foreach (var itemData in serialized) + { + if (!itemData.TryGetValue("name", out var nameValue) || nameValue is not string { Length: > 0 } name || !names.Add(name)) return serialized; } // Object format: use name as key var result = new Dictionary(); - foreach (var item in items) + for (var index = 0; index < items.Count; index++) { - var itemData = item.Save(context); - if (itemData.TryGetValue("name", out var nameValue) && nameValue is string name) - { - itemData.Remove("name"); + var item = items[index]; + var itemData = serialized[index]; + var name = (string)itemData["name"]!; + itemData.Remove("name"); - // Check if we can use shorthand - if (context.UseShorthand && Property.ShorthandProperty is string shorthandProp) + // Check if we can use shorthand + if (context.UseShorthand && Property.ShorthandProperty is string shorthandProp) + { + if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) { - if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) - { - result[name] = itemData[shorthandProp]; - continue; - } + result[name] = itemData[shorthandProp]; + continue; } - result[name] = itemData; - } - else - { - // No name, can't use object format for this item - throw new InvalidOperationException("Cannot save item in object format: missing 'name' property"); } + result[name] = itemData; } return result; @@ -472,36 +526,42 @@ public static object SaveOutputs(IList items, SaveContext? context) context ??= new SaveContext(); + var serialized = items.Select(item => new Dictionary(item.Save(context))).ToList(); + foreach (var itemData in serialized) + { + if (itemData.TryGetValue("name", out var nameValue) && nameValue is string { Length: 0 }) itemData.Remove("name"); + } + if (context.CollectionFormat == "array") { - return items.Select(item => item.Save(context)).ToList(); + return serialized; + } + + var names = new HashSet(StringComparer.Ordinal); + foreach (var itemData in serialized) + { + if (!itemData.TryGetValue("name", out var nameValue) || nameValue is not string { Length: > 0 } name || !names.Add(name)) return serialized; } // Object format: use name as key var result = new Dictionary(); - foreach (var item in items) + for (var index = 0; index < items.Count; index++) { - var itemData = item.Save(context); - if (itemData.TryGetValue("name", out var nameValue) && nameValue is string name) - { - itemData.Remove("name"); + var item = items[index]; + var itemData = serialized[index]; + var name = (string)itemData["name"]!; + itemData.Remove("name"); - // Check if we can use shorthand - if (context.UseShorthand && Property.ShorthandProperty is string shorthandProp) + // Check if we can use shorthand + if (context.UseShorthand && Property.ShorthandProperty is string shorthandProp) + { + if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) { - if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) - { - result[name] = itemData[shorthandProp]; - continue; - } + result[name] = itemData[shorthandProp]; + continue; } - result[name] = itemData; - } - else - { - // No name, can't use object format for this item - throw new InvalidOperationException("Cannot save item in object format: missing 'name' property"); } + result[name] = itemData; } return result; @@ -516,36 +576,42 @@ public static object SaveTools(IList items, SaveContext? context) context ??= new SaveContext(); + var serialized = items.Select(item => new Dictionary(item.Save(context))).ToList(); + foreach (var itemData in serialized) + { + if (itemData.TryGetValue("name", out var nameValue) && nameValue is string { Length: 0 }) itemData.Remove("name"); + } + if (context.CollectionFormat == "array") { - return items.Select(item => item.Save(context)).ToList(); + return serialized; + } + + var names = new HashSet(StringComparer.Ordinal); + foreach (var itemData in serialized) + { + if (!itemData.TryGetValue("name", out var nameValue) || nameValue is not string { Length: > 0 } name || !names.Add(name)) return serialized; } // Object format: use name as key var result = new Dictionary(); - foreach (var item in items) + for (var index = 0; index < items.Count; index++) { - var itemData = item.Save(context); - if (itemData.TryGetValue("name", out var nameValue) && nameValue is string name) - { - itemData.Remove("name"); + var item = items[index]; + var itemData = serialized[index]; + var name = (string)itemData["name"]!; + itemData.Remove("name"); - // Check if we can use shorthand - if (context.UseShorthand && Tool.ShorthandProperty is string shorthandProp) + // Check if we can use shorthand + if (context.UseShorthand && Tool.ShorthandProperty is string shorthandProp) + { + if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) { - if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) - { - result[name] = itemData[shorthandProp]; - continue; - } + result[name] = itemData[shorthandProp]; + continue; } - result[name] = itemData; - } - else - { - // No name, can't use object format for this item - throw new InvalidOperationException("Cannot save item in object format: missing 'name' property"); } + result[name] = itemData; } return result; diff --git a/runtime/csharp/Prompty.Core/Model/connection/AnonymousConnection.cs b/runtime/csharp/Prompty.Core/Model/connection/AnonymousConnection.cs index 8e3a42fe4..a6af1fdd3 100644 --- a/runtime/csharp/Prompty.Core/Model/connection/AnonymousConnection.cs +++ b/runtime/csharp/Prompty.Core/Model/connection/AnonymousConnection.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -47,6 +50,7 @@ public AnonymousConnection() /// The loaded AnonymousConnection instance. public new static AnonymousConnection Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -59,12 +63,22 @@ public AnonymousConnection() if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; + } + + if (data.TryGetValue("authenticationMode", out var authenticationModeValue) && authenticationModeValue is not null) + { + instance.AuthenticationMode = AuthenticationModeParser.Parse(authenticationModeValue.ToString()!); + } + + if (data.TryGetValue("usageDescription", out var usageDescriptionValue) && usageDescriptionValue is not null) + { + instance.UsageDescription = usageDescriptionValue.ToString()!; } if (data.TryGetValue("endpoint", out var endpointValue) && endpointValue is not null) { - instance.Endpoint = endpointValue?.ToString()!; + instance.Endpoint = endpointValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/connection/ApiKeyConnection.cs b/runtime/csharp/Prompty.Core/Model/connection/ApiKeyConnection.cs index edd365729..8283454ca 100644 --- a/runtime/csharp/Prompty.Core/Model/connection/ApiKeyConnection.cs +++ b/runtime/csharp/Prompty.Core/Model/connection/ApiKeyConnection.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public ApiKeyConnection() /// The loaded ApiKeyConnection instance. public new static ApiKeyConnection Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,17 +69,27 @@ public ApiKeyConnection() if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; + } + + if (data.TryGetValue("authenticationMode", out var authenticationModeValue) && authenticationModeValue is not null) + { + instance.AuthenticationMode = AuthenticationModeParser.Parse(authenticationModeValue.ToString()!); + } + + if (data.TryGetValue("usageDescription", out var usageDescriptionValue) && usageDescriptionValue is not null) + { + instance.UsageDescription = usageDescriptionValue.ToString()!; } if (data.TryGetValue("endpoint", out var endpointValue) && endpointValue is not null) { - instance.Endpoint = endpointValue?.ToString()!; + instance.Endpoint = endpointValue.ToString()!; } if (data.TryGetValue("apiKey", out var apiKeyValue) && apiKeyValue is not null) { - instance.ApiKey = apiKeyValue?.ToString()!; + instance.ApiKey = apiKeyValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/connection/AuthenticationMode.cs b/runtime/csharp/Prompty.Core/Model/connection/AuthenticationMode.cs index 0fc9b6642..0f4bcf782 100644 --- a/runtime/csharp/Prompty.Core/Model/connection/AuthenticationMode.cs +++ b/runtime/csharp/Prompty.Core/Model/connection/AuthenticationMode.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/connection/AuthorizationCodeFlow.cs b/runtime/csharp/Prompty.Core/Model/connection/AuthorizationCodeFlow.cs index d7be93541..8e7d50021 100644 --- a/runtime/csharp/Prompty.Core/Model/connection/AuthorizationCodeFlow.cs +++ b/runtime/csharp/Prompty.Core/Model/connection/AuthorizationCodeFlow.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,6 +51,7 @@ public AuthorizationCodeFlow() /// The loaded AuthorizationCodeFlow instance. public static AuthorizationCodeFlow Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -60,12 +64,12 @@ public static AuthorizationCodeFlow Load(Dictionary data, LoadC if (data.TryGetValue("authUrl", out var authUrlValue) && authUrlValue is not null) { - instance.AuthUrl = authUrlValue?.ToString()!; + instance.AuthUrl = authUrlValue.ToString()!; } if (data.TryGetValue("codeVerifier", out var codeVerifierValue) && codeVerifierValue is not null) { - instance.CodeVerifier = codeVerifierValue?.ToString()!; + instance.CodeVerifier = codeVerifierValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/connection/Connection.cs b/runtime/csharp/Prompty.Core/Model/connection/Connection.cs index 2d9095ea1..5b225999c 100644 --- a/runtime/csharp/Prompty.Core/Model/connection/Connection.cs +++ b/runtime/csharp/Prompty.Core/Model/connection/Connection.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -45,6 +48,26 @@ protected Connection() /// public string? UsageDescription { get; set; } + protected Dictionary _raw = new(); + + protected static object? CloneRawValue(object? value) + { + if (value is IDictionary dictionary) + { + return dictionary.ToDictionary(item => item.Key, item => CloneRawValue(item.Value)); + } + if (value is System.Collections.IEnumerable items && value is not string) + { + var result = new List(); + foreach (var item in items) + { + result.Add(CloneRawValue(item)); + } + return result; + } + return value; + } + #region Load Methods @@ -57,6 +80,7 @@ protected Connection() /// The loaded Connection instance. public static Connection Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -69,17 +93,17 @@ public static Connection Load(Dictionary data, LoadContext? con if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("authenticationMode", out var authenticationModeValue) && authenticationModeValue is not null) { - instance.AuthenticationMode = AuthenticationModeParser.Parse(authenticationModeValue?.ToString()!); + instance.AuthenticationMode = AuthenticationModeParser.Parse(authenticationModeValue.ToString()!); } if (data.TryGetValue("usageDescription", out var usageDescriptionValue) && usageDescriptionValue is not null) { - instance.UsageDescription = usageDescriptionValue?.ToString()!; + instance.UsageDescription = usageDescriptionValue.ToString()!; } if (context is not null) @@ -97,7 +121,7 @@ private static Connection LoadKind(Dictionary data, LoadContext { if (data.TryGetValue("kind", out var discriminatorValue) && discriminatorValue is not null) { - var discriminator = discriminatorValue.ToString()?.ToLowerInvariant(); + var discriminator = discriminatorValue.ToString(); return discriminator switch { "reference" => ReferenceConnection.Load(data, context), @@ -106,11 +130,11 @@ private static Connection LoadKind(Dictionary data, LoadContext "anonymous" => AnonymousConnection.Load(data, context), "oauth" => OAuthConnection.Load(data, context), "foundry" => FoundryConnection.Load(data, context), - _ => throw new ArgumentException($"Unknown Connection discriminator value: {discriminator}"), + _ => UnknownConnection.Load(data, context), }; } - throw new ArgumentException("Missing Connection discriminator property: 'kind'"); + return UnknownConnection.Load(data, context); } @@ -133,7 +157,7 @@ private static Connection LoadKind(Dictionary data, LoadContext } - var result = new Dictionary(); + var result = (Dictionary)CloneRawValue(obj._raw)!; result["kind"] = obj.Kind; @@ -215,3 +239,25 @@ public static Connection FromYaml(string yaml, LoadContext? context = null) #endregion } + +/// +/// Carries a Connection whose discriminator value matches no known subtype. +/// The unrecognized value stays on the discriminator property and every key the schema +/// does not declare is preserved verbatim, so an unknown Connection survives a +/// load/save round-trip unchanged. +/// +public sealed partial class UnknownConnection : Connection +{ + /// + /// Load an unrecognized Connection, retaining its complete payload. + /// + public static new UnknownConnection Load(Dictionary data, LoadContext? context = null) + { + var instance = new UnknownConnection(); + instance._raw = (Dictionary)CloneRawValue(data)!; + instance._raw.Remove("kind"); + instance._raw.Remove("authenticationMode"); + instance._raw.Remove("usageDescription"); + return instance; + } +} diff --git a/runtime/csharp/Prompty.Core/Model/connection/DeviceAuthorization.cs b/runtime/csharp/Prompty.Core/Model/connection/DeviceAuthorization.cs index 0111e0002..83da3831d 100644 --- a/runtime/csharp/Prompty.Core/Model/connection/DeviceAuthorization.cs +++ b/runtime/csharp/Prompty.Core/Model/connection/DeviceAuthorization.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -68,6 +71,7 @@ public DeviceAuthorization() /// The loaded DeviceAuthorization instance. public static DeviceAuthorization Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -80,17 +84,17 @@ public static DeviceAuthorization Load(Dictionary data, LoadCon if (data.TryGetValue("deviceCode", out var deviceCodeValue) && deviceCodeValue is not null) { - instance.DeviceCode = deviceCodeValue?.ToString()!; + instance.DeviceCode = deviceCodeValue.ToString()!; } if (data.TryGetValue("userCode", out var userCodeValue) && userCodeValue is not null) { - instance.UserCode = userCodeValue?.ToString()!; + instance.UserCode = userCodeValue.ToString()!; } if (data.TryGetValue("verificationUri", out var verificationUriValue) && verificationUriValue is not null) { - instance.VerificationUri = verificationUriValue?.ToString()!; + instance.VerificationUri = verificationUriValue.ToString()!; } if (data.TryGetValue("expiresIn", out var expiresInValue) && expiresInValue is not null) @@ -105,7 +109,7 @@ public static DeviceAuthorization Load(Dictionary data, LoadCon if (data.TryGetValue("message", out var messageValue) && messageValue is not null) { - instance.Message = messageValue?.ToString()!; + instance.Message = messageValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/connection/FoundryConnection.cs b/runtime/csharp/Prompty.Core/Model/connection/FoundryConnection.cs index 7e427c774..34c8928e8 100644 --- a/runtime/csharp/Prompty.Core/Model/connection/FoundryConnection.cs +++ b/runtime/csharp/Prompty.Core/Model/connection/FoundryConnection.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -62,6 +65,7 @@ public FoundryConnection() /// The loaded FoundryConnection instance. public new static FoundryConnection Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -74,22 +78,32 @@ public FoundryConnection() if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; + } + + if (data.TryGetValue("authenticationMode", out var authenticationModeValue) && authenticationModeValue is not null) + { + instance.AuthenticationMode = AuthenticationModeParser.Parse(authenticationModeValue.ToString()!); + } + + if (data.TryGetValue("usageDescription", out var usageDescriptionValue) && usageDescriptionValue is not null) + { + instance.UsageDescription = usageDescriptionValue.ToString()!; } if (data.TryGetValue("endpoint", out var endpointValue) && endpointValue is not null) { - instance.Endpoint = endpointValue?.ToString()!; + instance.Endpoint = endpointValue.ToString()!; } if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("connectionType", out var connectionTypeValue) && connectionTypeValue is not null) { - instance.ConnectionType = connectionTypeValue?.ToString()!; + instance.ConnectionType = connectionTypeValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/connection/OAuthConnection.cs b/runtime/csharp/Prompty.Core/Model/connection/OAuthConnection.cs index cef46b2d8..73e51ad97 100644 --- a/runtime/csharp/Prompty.Core/Model/connection/OAuthConnection.cs +++ b/runtime/csharp/Prompty.Core/Model/connection/OAuthConnection.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -72,6 +75,7 @@ public OAuthConnection() /// The loaded OAuthConnection instance. public new static OAuthConnection Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -84,27 +88,37 @@ public OAuthConnection() if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; + } + + if (data.TryGetValue("authenticationMode", out var authenticationModeValue) && authenticationModeValue is not null) + { + instance.AuthenticationMode = AuthenticationModeParser.Parse(authenticationModeValue.ToString()!); + } + + if (data.TryGetValue("usageDescription", out var usageDescriptionValue) && usageDescriptionValue is not null) + { + instance.UsageDescription = usageDescriptionValue.ToString()!; } if (data.TryGetValue("endpoint", out var endpointValue) && endpointValue is not null) { - instance.Endpoint = endpointValue?.ToString()!; + instance.Endpoint = endpointValue.ToString()!; } if (data.TryGetValue("clientId", out var clientIdValue) && clientIdValue is not null) { - instance.ClientId = clientIdValue?.ToString()!; + instance.ClientId = clientIdValue.ToString()!; } if (data.TryGetValue("clientSecret", out var clientSecretValue) && clientSecretValue is not null) { - instance.ClientSecret = clientSecretValue?.ToString()!; + instance.ClientSecret = clientSecretValue.ToString()!; } if (data.TryGetValue("tokenUrl", out var tokenUrlValue) && tokenUrlValue is not null) { - instance.TokenUrl = tokenUrlValue?.ToString()!; + instance.TokenUrl = tokenUrlValue.ToString()!; } if (data.TryGetValue("scopes", out var scopesValue) && scopesValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/connection/OAuthToken.cs b/runtime/csharp/Prompty.Core/Model/connection/OAuthToken.cs index abea564f4..292606429 100644 --- a/runtime/csharp/Prompty.Core/Model/connection/OAuthToken.cs +++ b/runtime/csharp/Prompty.Core/Model/connection/OAuthToken.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -63,6 +66,7 @@ public OAuthToken() /// The loaded OAuthToken instance. public static OAuthToken Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -75,12 +79,12 @@ public static OAuthToken Load(Dictionary data, LoadContext? con if (data.TryGetValue("accessToken", out var accessTokenValue) && accessTokenValue is not null) { - instance.AccessToken = accessTokenValue?.ToString()!; + instance.AccessToken = accessTokenValue.ToString()!; } if (data.TryGetValue("tokenType", out var tokenTypeValue) && tokenTypeValue is not null) { - instance.TokenType = tokenTypeValue?.ToString()!; + instance.TokenType = tokenTypeValue.ToString()!; } if (data.TryGetValue("expiresIn", out var expiresInValue) && expiresInValue is not null) @@ -90,12 +94,12 @@ public static OAuthToken Load(Dictionary data, LoadContext? con if (data.TryGetValue("refreshToken", out var refreshTokenValue) && refreshTokenValue is not null) { - instance.RefreshToken = refreshTokenValue?.ToString()!; + instance.RefreshToken = refreshTokenValue.ToString()!; } if (data.TryGetValue("scope", out var scopeValue) && scopeValue is not null) { - instance.Scope = scopeValue?.ToString()!; + instance.Scope = scopeValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/connection/ReferenceConnection.cs b/runtime/csharp/Prompty.Core/Model/connection/ReferenceConnection.cs index 861ef3697..ae5c73a8d 100644 --- a/runtime/csharp/Prompty.Core/Model/connection/ReferenceConnection.cs +++ b/runtime/csharp/Prompty.Core/Model/connection/ReferenceConnection.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public ReferenceConnection() /// The loaded ReferenceConnection instance. public new static ReferenceConnection Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,17 +69,27 @@ public ReferenceConnection() if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; + } + + if (data.TryGetValue("authenticationMode", out var authenticationModeValue) && authenticationModeValue is not null) + { + instance.AuthenticationMode = AuthenticationModeParser.Parse(authenticationModeValue.ToString()!); + } + + if (data.TryGetValue("usageDescription", out var usageDescriptionValue) && usageDescriptionValue is not null) + { + instance.UsageDescription = usageDescriptionValue.ToString()!; } if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("target", out var targetValue) && targetValue is not null) { - instance.Target = targetValue?.ToString()!; + instance.Target = targetValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/connection/RemoteConnection.cs b/runtime/csharp/Prompty.Core/Model/connection/RemoteConnection.cs index 89d7c71ae..9169c0a24 100644 --- a/runtime/csharp/Prompty.Core/Model/connection/RemoteConnection.cs +++ b/runtime/csharp/Prompty.Core/Model/connection/RemoteConnection.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public RemoteConnection() /// The loaded RemoteConnection instance. public new static RemoteConnection Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,17 +69,27 @@ public RemoteConnection() if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; + } + + if (data.TryGetValue("authenticationMode", out var authenticationModeValue) && authenticationModeValue is not null) + { + instance.AuthenticationMode = AuthenticationModeParser.Parse(authenticationModeValue.ToString()!); + } + + if (data.TryGetValue("usageDescription", out var usageDescriptionValue) && usageDescriptionValue is not null) + { + instance.UsageDescription = usageDescriptionValue.ToString()!; } if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("endpoint", out var endpointValue) && endpointValue is not null) { - instance.Endpoint = endpointValue?.ToString()!; + instance.Endpoint = endpointValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/conversation/AudioPart.cs b/runtime/csharp/Prompty.Core/Model/conversation/AudioPart.cs index 79b69e4f3..253984c00 100644 --- a/runtime/csharp/Prompty.Core/Model/conversation/AudioPart.cs +++ b/runtime/csharp/Prompty.Core/Model/conversation/AudioPart.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public AudioPart() /// The loaded AudioPart instance. public new static AudioPart Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,17 +69,17 @@ public AudioPart() if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("source", out var sourceValue) && sourceValue is not null) { - instance.Source = sourceValue?.ToString()!; + instance.Source = sourceValue.ToString()!; } if (data.TryGetValue("mediaType", out var mediaTypeValue) && mediaTypeValue is not null) { - instance.MediaType = mediaTypeValue?.ToString()!; + instance.MediaType = mediaTypeValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/conversation/ContentPart.cs b/runtime/csharp/Prompty.Core/Model/conversation/ContentPart.cs index 04d7e6cc8..3bf806f4e 100644 --- a/runtime/csharp/Prompty.Core/Model/conversation/ContentPart.cs +++ b/runtime/csharp/Prompty.Core/Model/conversation/ContentPart.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -45,6 +48,7 @@ protected ContentPart() /// The loaded ContentPart instance. public static ContentPart Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -57,7 +61,7 @@ public static ContentPart Load(Dictionary data, LoadContext? co if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (context is not null) @@ -75,14 +79,14 @@ private static ContentPart LoadKind(Dictionary data, LoadContex { if (data.TryGetValue("kind", out var discriminatorValue) && discriminatorValue is not null) { - var discriminator = discriminatorValue.ToString()?.ToLowerInvariant(); + var discriminator = discriminatorValue.ToString(); return discriminator switch { "text" => TextPart.Load(data, context), "image" => ImagePart.Load(data, context), "file" => FilePart.Load(data, context), "audio" => AudioPart.Load(data, context), - _ => throw new ArgumentException($"Unknown ContentPart discriminator value: {discriminator}"), + _ => throw new ArgumentException($"Unknown ContentPart discriminator field 'kind' value: {discriminator}"), }; } diff --git a/runtime/csharp/Prompty.Core/Model/conversation/FilePart.cs b/runtime/csharp/Prompty.Core/Model/conversation/FilePart.cs index ef9d3dad4..00eb6b624 100644 --- a/runtime/csharp/Prompty.Core/Model/conversation/FilePart.cs +++ b/runtime/csharp/Prompty.Core/Model/conversation/FilePart.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public FilePart() /// The loaded FilePart instance. public new static FilePart Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,17 +69,17 @@ public FilePart() if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("source", out var sourceValue) && sourceValue is not null) { - instance.Source = sourceValue?.ToString()!; + instance.Source = sourceValue.ToString()!; } if (data.TryGetValue("mediaType", out var mediaTypeValue) && mediaTypeValue is not null) { - instance.MediaType = mediaTypeValue?.ToString()!; + instance.MediaType = mediaTypeValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/conversation/ImagePart.cs b/runtime/csharp/Prompty.Core/Model/conversation/ImagePart.cs index 8d131e528..f837efa23 100644 --- a/runtime/csharp/Prompty.Core/Model/conversation/ImagePart.cs +++ b/runtime/csharp/Prompty.Core/Model/conversation/ImagePart.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -58,6 +61,7 @@ public ImagePart() /// The loaded ImagePart instance. public new static ImagePart Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -70,22 +74,22 @@ public ImagePart() if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("source", out var sourceValue) && sourceValue is not null) { - instance.Source = sourceValue?.ToString()!; + instance.Source = sourceValue.ToString()!; } if (data.TryGetValue("detail", out var detailValue) && detailValue is not null) { - instance.Detail = detailValue?.ToString()!; + instance.Detail = detailValue.ToString()!; } if (data.TryGetValue("mediaType", out var mediaTypeValue) && mediaTypeValue is not null) { - instance.MediaType = mediaTypeValue?.ToString()!; + instance.MediaType = mediaTypeValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/conversation/Message.cs b/runtime/csharp/Prompty.Core/Model/conversation/Message.cs index c83a73ac6..27f8ae0cf 100644 --- a/runtime/csharp/Prompty.Core/Model/conversation/Message.cs +++ b/runtime/csharp/Prompty.Core/Model/conversation/Message.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -39,9 +42,9 @@ public Message() public IList Parts { get; set; } = []; /// - /// Optional metadata associated with the message + /// Optional metadata associated with the message. Values may be explicit null. /// - public IDictionary Metadata { get; set; } = new Dictionary(); + public IDictionary Metadata { get; set; } = new Dictionary(); @@ -55,6 +58,7 @@ public Message() /// The loaded Message instance. public static Message Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -67,12 +71,12 @@ public static Message Load(Dictionary data, LoadContext? contex if (data.TryGetValue("role", out var roleValue) && roleValue is not null) { - instance.Role = RoleParser.Parse(roleValue?.ToString()!); + instance.Role = RoleParser.Parse(roleValue.ToString()!); } if (data.TryGetValue("parts", out var partsValue) && partsValue is not null) { - instance.Parts = LoadParts(partsValue, context); + instance.Parts = LoadParts(partsValue, context!.At("parts")); } if (data.TryGetValue("metadata", out var metadataValue) && metadataValue is not null) @@ -95,46 +99,49 @@ public static IList LoadParts(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'parts' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "parts" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'parts' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(ContentPart.Load(itemDict, context)); + result.Add(ContentPart.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["kind"] = kvp.Value }; - result.Add(ContentPart.Load(newDict, context)); + result.Add(ContentPart.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(ContentPart.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(ContentPart.Load(itemDict, context)); + result.Add(ContentPart.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/conversation/Role.cs b/runtime/csharp/Prompty.Core/Model/conversation/Role.cs index 540dffa0a..4aa5a10f4 100644 --- a/runtime/csharp/Prompty.Core/Model/conversation/Role.cs +++ b/runtime/csharp/Prompty.Core/Model/conversation/Role.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/conversation/TextPart.cs b/runtime/csharp/Prompty.Core/Model/conversation/TextPart.cs index 009ecc411..06a82d8d4 100644 --- a/runtime/csharp/Prompty.Core/Model/conversation/TextPart.cs +++ b/runtime/csharp/Prompty.Core/Model/conversation/TextPart.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,6 +51,7 @@ public TextPart() /// The loaded TextPart instance. public new static TextPart Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -60,12 +64,12 @@ public TextPart() if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("value", out var valueValue) && valueValue is not null) { - instance.Value = valueValue?.ToString()!; + instance.Value = valueValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/conversation/ThreadMarker.cs b/runtime/csharp/Prompty.Core/Model/conversation/ThreadMarker.cs index 6671442db..b4a8b4eff 100644 --- a/runtime/csharp/Prompty.Core/Model/conversation/ThreadMarker.cs +++ b/runtime/csharp/Prompty.Core/Model/conversation/ThreadMarker.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -54,6 +57,7 @@ public ThreadMarker() /// The loaded ThreadMarker instance. public static ThreadMarker Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -66,12 +70,12 @@ public static ThreadMarker Load(Dictionary data, LoadContext? c if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/conversation/ToolCall.cs b/runtime/csharp/Prompty.Core/Model/conversation/ToolCall.cs index e63d86c91..0438f4187 100644 --- a/runtime/csharp/Prompty.Core/Model/conversation/ToolCall.cs +++ b/runtime/csharp/Prompty.Core/Model/conversation/ToolCall.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -55,6 +58,7 @@ public ToolCall() /// The loaded ToolCall instance. public static ToolCall Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -67,17 +71,17 @@ public static ToolCall Load(Dictionary data, LoadContext? conte if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("arguments", out var argumentsValue) && argumentsValue is not null) { - instance.Arguments = argumentsValue?.ToString()!; + instance.Arguments = argumentsValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/conversation/ToolResult.cs b/runtime/csharp/Prompty.Core/Model/conversation/ToolResult.cs index 23e65bb4c..8f7ba03d6 100644 --- a/runtime/csharp/Prompty.Core/Model/conversation/ToolResult.cs +++ b/runtime/csharp/Prompty.Core/Model/conversation/ToolResult.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -69,6 +72,7 @@ public ToolResult() /// The loaded ToolResult instance. public static ToolResult Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -81,22 +85,22 @@ public static ToolResult Load(Dictionary data, LoadContext? con if (data.TryGetValue("parts", out var partsValue) && partsValue is not null) { - instance.Parts = LoadParts(partsValue, context); + instance.Parts = LoadParts(partsValue, context!.At("parts")); } if (data.TryGetValue("status", out var statusValue) && statusValue is not null) { - instance.Status = ToolResultStatusParser.Parse(statusValue?.ToString()!); + instance.Status = ToolResultStatusParser.Parse(statusValue.ToString()!); } if (data.TryGetValue("errorKind", out var errorKindValue) && errorKindValue is not null) { - instance.ErrorKind = errorKindValue?.ToString()!; + instance.ErrorKind = errorKindValue.ToString()!; } if (data.TryGetValue("errorMessage", out var errorMessageValue) && errorMessageValue is not null) { - instance.ErrorMessage = errorMessageValue?.ToString()!; + instance.ErrorMessage = errorMessageValue.ToString()!; } if (data.TryGetValue("durationMs", out var durationMsValue) && durationMsValue is not null) @@ -119,46 +123,49 @@ public static IList LoadParts(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'parts' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "parts" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'parts' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(ContentPart.Load(itemDict, context)); + result.Add(ContentPart.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["kind"] = kvp.Value }; - result.Add(ContentPart.Load(newDict, context)); + result.Add(ContentPart.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(ContentPart.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(ContentPart.Load(itemDict, context)); + result.Add(ContentPart.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/conversation/ToolResultStatus.cs b/runtime/csharp/Prompty.Core/Model/conversation/ToolResultStatus.cs index ea7c6e5ce..1255aca5d 100644 --- a/runtime/csharp/Prompty.Core/Model/conversation/ToolResultStatus.cs +++ b/runtime/csharp/Prompty.Core/Model/conversation/ToolResultStatus.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/core/ArrayProperty.cs b/runtime/csharp/Prompty.Core/Model/core/ArrayProperty.cs index b1b3caeb8..ef2130ff3 100644 --- a/runtime/csharp/Prompty.Core/Model/core/ArrayProperty.cs +++ b/runtime/csharp/Prompty.Core/Model/core/ArrayProperty.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -36,7 +39,7 @@ public ArrayProperty() /// /// The type of items contained in the array /// - public Property Items { get; set; } + public Property? Items { get; set; } @@ -50,6 +53,7 @@ public ArrayProperty() /// The loaded ArrayProperty instance. public new static ArrayProperty Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -60,14 +64,49 @@ public ArrayProperty() var instance = new ArrayProperty(); + if (data.TryGetValue("name", out var nameValue) && nameValue is not null) + { + instance.Name = nameValue.ToString()!; + } + if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; + } + + if (data.TryGetValue("description", out var descriptionValue) && descriptionValue is not null) + { + instance.Description = descriptionValue.ToString()!; + } + + if (data.TryGetValue("required", out var requiredValue) && requiredValue is not null) + { + instance.Required = Convert.ToBoolean(requiredValue); + } + + if (data.TryGetValue("nullable", out var nullableValue) && nullableValue is not null) + { + instance.Nullable = Convert.ToBoolean(nullableValue); + } + + if (data.TryGetValue("default", out var defaultValue) && defaultValue is not null) + { + instance.Default = defaultValue; + } + + if (data.TryGetValue("example", out var exampleValue) && exampleValue is not null) + { + instance.Example = exampleValue; + } + + if (data.TryGetValue("enumValues", out var enumValuesValue) && enumValuesValue is not null) + { + instance.EnumValues = (enumValuesValue as IEnumerable)?.ToList() ?? []; } if (data.TryGetValue("items", out var itemsValue) && itemsValue is not null) { - instance.Items = Property.Load(itemsValue.GetDictionary(Property.ShorthandProperty), context); + instance.Items = Property.Load(itemsValue.GetDictionary(Property.ShorthandProperty), context!.At("items")); } if (context is not null) @@ -103,7 +142,10 @@ public ArrayProperty() result["kind"] = obj.Kind; - result["items"] = obj.Items?.Save(context); + if (obj.Items is not null) + { + result["items"] = obj.Items?.Save(context); + } return result; diff --git a/runtime/csharp/Prompty.Core/Model/core/FileNotFoundError.cs b/runtime/csharp/Prompty.Core/Model/core/FileNotFoundError.cs index 083dc7591..505cd6b1d 100644 --- a/runtime/csharp/Prompty.Core/Model/core/FileNotFoundError.cs +++ b/runtime/csharp/Prompty.Core/Model/core/FileNotFoundError.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -50,6 +53,7 @@ public FileNotFoundError() /// The loaded FileNotFoundError instance. public static FileNotFoundError Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -62,12 +66,12 @@ public static FileNotFoundError Load(Dictionary data, LoadConte if (data.TryGetValue("message", out var messageValue) && messageValue is not null) { - instance.Message = messageValue?.ToString()!; + instance.Message = messageValue.ToString()!; } if (data.TryGetValue("path", out var pathValue) && pathValue is not null) { - instance.Path = pathValue?.ToString()!; + instance.Path = pathValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/core/InvokerError.cs b/runtime/csharp/Prompty.Core/Model/core/InvokerError.cs index 4cb68a7c8..59aecfc88 100644 --- a/runtime/csharp/Prompty.Core/Model/core/InvokerError.cs +++ b/runtime/csharp/Prompty.Core/Model/core/InvokerError.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -57,6 +60,7 @@ public InvokerError() /// The loaded InvokerError instance. public static InvokerError Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -69,17 +73,17 @@ public static InvokerError Load(Dictionary data, LoadContext? c if (data.TryGetValue("message", out var messageValue) && messageValue is not null) { - instance.Message = messageValue?.ToString()!; + instance.Message = messageValue.ToString()!; } if (data.TryGetValue("component", out var componentValue) && componentValue is not null) { - instance.Component = componentValue?.ToString()!; + instance.Component = componentValue.ToString()!; } if (data.TryGetValue("key", out var keyValue) && keyValue is not null) { - instance.Key = keyValue?.ToString()!; + instance.Key = keyValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/core/ObjectProperty.cs b/runtime/csharp/Prompty.Core/Model/core/ObjectProperty.cs index 097174f5a..f32b081a4 100644 --- a/runtime/csharp/Prompty.Core/Model/core/ObjectProperty.cs +++ b/runtime/csharp/Prompty.Core/Model/core/ObjectProperty.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -50,6 +53,7 @@ public ObjectProperty() /// The loaded ObjectProperty instance. public new static ObjectProperty Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -60,14 +64,49 @@ public ObjectProperty() var instance = new ObjectProperty(); + if (data.TryGetValue("name", out var nameValue) && nameValue is not null) + { + instance.Name = nameValue.ToString()!; + } + if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; + } + + if (data.TryGetValue("description", out var descriptionValue) && descriptionValue is not null) + { + instance.Description = descriptionValue.ToString()!; + } + + if (data.TryGetValue("required", out var requiredValue) && requiredValue is not null) + { + instance.Required = Convert.ToBoolean(requiredValue); + } + + if (data.TryGetValue("nullable", out var nullableValue) && nullableValue is not null) + { + instance.Nullable = Convert.ToBoolean(nullableValue); + } + + if (data.TryGetValue("default", out var defaultValue) && defaultValue is not null) + { + instance.Default = defaultValue; + } + + if (data.TryGetValue("example", out var exampleValue) && exampleValue is not null) + { + instance.Example = exampleValue; + } + + if (data.TryGetValue("enumValues", out var enumValuesValue) && enumValuesValue is not null) + { + instance.EnumValues = (enumValuesValue as IEnumerable)?.ToList() ?? []; } if (data.TryGetValue("properties", out var propertiesValue) && propertiesValue is not null) { - instance.Properties = LoadProperties(propertiesValue, context); + instance.Properties = LoadProperties(propertiesValue, context!.At("properties")); } if (context is not null) @@ -85,46 +124,65 @@ public static IList LoadProperties(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'properties' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "properties" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'properties' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Property.Load(itemDict, context)); + result.Add(Property.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, - ["example"] = kvp.Value + ["default"] = kvp.Value }; - result.Add(Property.Load(newDict, context)); + if (kvp.Value is int or long or short or byte) + { + newDict["kind"] = "integer"; + } + else if (kvp.Value is double or float or decimal) + { + newDict["kind"] = "float"; + } + else if (kvp.Value is string) + { + newDict["kind"] = "string"; + } + else if (kvp.Value is bool) + { + newDict["kind"] = "boolean"; + } + result.Add(Property.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Property.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Property.Load(itemDict, context)); + result.Add(Property.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -172,36 +230,42 @@ public static object SaveProperties(IList items, SaveContext? context) context ??= new SaveContext(); + var serialized = items.Select(item => new Dictionary(item.Save(context))).ToList(); + foreach (var itemData in serialized) + { + if (itemData.TryGetValue("name", out var nameValue) && nameValue is string { Length: 0 }) itemData.Remove("name"); + } + if (context.CollectionFormat == "array") { - return items.Select(item => item.Save(context)).ToList(); + return serialized; + } + + var names = new HashSet(StringComparer.Ordinal); + foreach (var itemData in serialized) + { + if (!itemData.TryGetValue("name", out var nameValue) || nameValue is not string { Length: > 0 } name || !names.Add(name)) return serialized; } // Object format: use name as key var result = new Dictionary(); - foreach (var item in items) + for (var index = 0; index < items.Count; index++) { - var itemData = item.Save(context); - if (itemData.TryGetValue("name", out var nameValue) && nameValue is string name) - { - itemData.Remove("name"); + var item = items[index]; + var itemData = serialized[index]; + var name = (string)itemData["name"]!; + itemData.Remove("name"); - // Check if we can use shorthand - if (context.UseShorthand && Property.ShorthandProperty is string shorthandProp) + // Check if we can use shorthand + if (context.UseShorthand && Property.ShorthandProperty is string shorthandProp) + { + if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) { - if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) - { - result[name] = itemData[shorthandProp]; - continue; - } + result[name] = itemData[shorthandProp]; + continue; } - result[name] = itemData; - } - else - { - // No name, can't use object format for this item - throw new InvalidOperationException("Cannot save item in object format: missing 'name' property"); } + result[name] = itemData; } return result; diff --git a/runtime/csharp/Prompty.Core/Model/core/Property.cs b/runtime/csharp/Prompty.Core/Model/core/Property.cs index f567a9490..915950538 100644 --- a/runtime/csharp/Prompty.Core/Model/core/Property.cs +++ b/runtime/csharp/Prompty.Core/Model/core/Property.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -72,7 +75,27 @@ public Property() /// /// Allowed enumeration values for the property /// - public IList? EnumValues { get; set; } + public IList? EnumValues { get; set; } = []; + + protected Dictionary _raw = new(); + + protected static object? CloneRawValue(object? value) + { + if (value is IDictionary dictionary) + { + return dictionary.ToDictionary(item => item.Key, item => CloneRawValue(item.Value)); + } + if (value is System.Collections.IEnumerable items && value is not string) + { + var result = new List(); + foreach (var item in items) + { + result.Add(CloneRawValue(item)); + } + return result; + } + return value; + } @@ -86,6 +109,7 @@ public Property() /// The loaded Property instance. public static Property Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -99,17 +123,17 @@ public static Property Load(Dictionary data, LoadContext? conte if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("description", out var descriptionValue) && descriptionValue is not null) { - instance.Description = descriptionValue?.ToString()!; + instance.Description = descriptionValue.ToString()!; } if (data.TryGetValue("required", out var requiredValue) && requiredValue is not null) @@ -137,6 +161,11 @@ public static Property Load(Dictionary data, LoadContext? conte instance.EnumValues = (enumValuesValue as IEnumerable)?.ToList() ?? []; } + if (instance.GetType() == typeof(Property)) + { + instance._raw = (Dictionary)CloneRawValue(data)!; + } + if (context is not null) { instance = context.ProcessOutput(instance); @@ -152,7 +181,7 @@ private static Property LoadKind(Dictionary data, LoadContext? { if (data.TryGetValue("kind", out var discriminatorValue) && discriminatorValue is not null) { - var discriminator = discriminatorValue.ToString()?.ToLowerInvariant(); + var discriminator = discriminatorValue.ToString(); return discriminator switch { "array" => ArrayProperty.Load(data, context), @@ -185,7 +214,7 @@ private static Property LoadKind(Dictionary data, LoadContext? } - var result = new Dictionary(); + var result = (Dictionary)CloneRawValue(obj._raw)!; result["name"] = obj.Name; diff --git a/runtime/csharp/Prompty.Core/Model/core/UnionProperty.cs b/runtime/csharp/Prompty.Core/Model/core/UnionProperty.cs index b90dfba7c..4041de685 100644 --- a/runtime/csharp/Prompty.Core/Model/core/UnionProperty.cs +++ b/runtime/csharp/Prompty.Core/Model/core/UnionProperty.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -63,6 +66,7 @@ public UnionProperty() /// The loaded UnionProperty instance. public new static UnionProperty Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -73,19 +77,54 @@ public UnionProperty() var instance = new UnionProperty(); + if (data.TryGetValue("name", out var nameValue) && nameValue is not null) + { + instance.Name = nameValue.ToString()!; + } + if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; + } + + if (data.TryGetValue("description", out var descriptionValue) && descriptionValue is not null) + { + instance.Description = descriptionValue.ToString()!; + } + + if (data.TryGetValue("required", out var requiredValue) && requiredValue is not null) + { + instance.Required = Convert.ToBoolean(requiredValue); + } + + if (data.TryGetValue("nullable", out var nullableValue) && nullableValue is not null) + { + instance.Nullable = Convert.ToBoolean(nullableValue); + } + + if (data.TryGetValue("default", out var defaultValue) && defaultValue is not null) + { + instance.Default = defaultValue; + } + + if (data.TryGetValue("example", out var exampleValue) && exampleValue is not null) + { + instance.Example = exampleValue; + } + + if (data.TryGetValue("enumValues", out var enumValuesValue) && enumValuesValue is not null) + { + instance.EnumValues = (enumValuesValue as IEnumerable)?.ToList() ?? []; } if (data.TryGetValue("oneOf", out var oneOfValue) && oneOfValue is not null) { - instance.OneOf = LoadOneOf(oneOfValue, context); + instance.OneOf = LoadOneOf(oneOfValue, context!.At("oneOf")); } if (data.TryGetValue("anyOf", out var anyOfValue) && anyOfValue is not null) { - instance.AnyOf = LoadAnyOf(anyOfValue, context); + instance.AnyOf = LoadAnyOf(anyOfValue, context!.At("anyOf")); } if (context is not null) @@ -103,46 +142,65 @@ public static IList LoadOneOf(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'oneOf' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "oneOf" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'oneOf' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Property.Load(itemDict, context)); + result.Add(Property.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, - ["example"] = kvp.Value + ["default"] = kvp.Value }; - result.Add(Property.Load(newDict, context)); + if (kvp.Value is int or long or short or byte) + { + newDict["kind"] = "integer"; + } + else if (kvp.Value is double or float or decimal) + { + newDict["kind"] = "float"; + } + else if (kvp.Value is string) + { + newDict["kind"] = "string"; + } + else if (kvp.Value is bool) + { + newDict["kind"] = "boolean"; + } + result.Add(Property.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Property.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Property.Load(itemDict, context)); + result.Add(Property.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -157,46 +215,65 @@ public static IList LoadAnyOf(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'anyOf' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "anyOf" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'anyOf' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Property.Load(itemDict, context)); + result.Add(Property.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, - ["example"] = kvp.Value + ["default"] = kvp.Value }; - result.Add(Property.Load(newDict, context)); + if (kvp.Value is int or long or short or byte) + { + newDict["kind"] = "integer"; + } + else if (kvp.Value is double or float or decimal) + { + newDict["kind"] = "float"; + } + else if (kvp.Value is string) + { + newDict["kind"] = "string"; + } + else if (kvp.Value is bool) + { + newDict["kind"] = "boolean"; + } + result.Add(Property.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Property.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Property.Load(itemDict, context)); + result.Add(Property.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/core/ValidationError.cs b/runtime/csharp/Prompty.Core/Model/core/ValidationError.cs index aaab609de..35278a07a 100644 --- a/runtime/csharp/Prompty.Core/Model/core/ValidationError.cs +++ b/runtime/csharp/Prompty.Core/Model/core/ValidationError.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -55,6 +58,7 @@ public ValidationError() /// The loaded ValidationError instance. public static ValidationError Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -67,17 +71,17 @@ public static ValidationError Load(Dictionary data, LoadContext if (data.TryGetValue("message", out var messageValue) && messageValue is not null) { - instance.Message = messageValue?.ToString()!; + instance.Message = messageValue.ToString()!; } if (data.TryGetValue("property", out var propertyValue) && propertyValue is not null) { - instance.Property = propertyValue?.ToString()!; + instance.Property = propertyValue.ToString()!; } if (data.TryGetValue("constraint", out var constraintValue) && constraintValue is not null) { - instance.Constraint = constraintValue?.ToString()!; + instance.Constraint = constraintValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/core/ValidationResult.cs b/runtime/csharp/Prompty.Core/Model/core/ValidationResult.cs index 9694a60c4..d398aea85 100644 --- a/runtime/csharp/Prompty.Core/Model/core/ValidationResult.cs +++ b/runtime/csharp/Prompty.Core/Model/core/ValidationResult.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -52,6 +55,7 @@ public ValidationResult() /// The loaded ValidationResult instance. public static ValidationResult Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -69,7 +73,7 @@ public static ValidationResult Load(Dictionary data, LoadContex if (data.TryGetValue("errors", out var errorsValue) && errorsValue is not null) { - instance.Errors = LoadErrors(errorsValue, context); + instance.Errors = LoadErrors(errorsValue, context!.At("errors")); } if (context is not null) @@ -87,46 +91,49 @@ public static IList LoadErrors(object data, LoadContext? contex { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'errors' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "errors" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'errors' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(ValidationError.Load(itemDict, context)); + result.Add(ValidationError.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["message"] = kvp.Value }; - result.Add(ValidationError.Load(newDict, context)); + result.Add(ValidationError.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(ValidationError.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(ValidationError.Load(itemDict, context)); + result.Add(ValidationError.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/events/Checkpoint.cs b/runtime/csharp/Prompty.Core/Model/events/Checkpoint.cs index 858754c8e..fa6a04edb 100644 --- a/runtime/csharp/Prompty.Core/Model/events/Checkpoint.cs +++ b/runtime/csharp/Prompty.Core/Model/events/Checkpoint.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -59,7 +62,7 @@ public Checkpoint() /// /// Portable checkpoint state needed to resume or hand off the session /// - public IDictionary? State { get; set; } + public IDictionary? State { get; set; } /// /// Optional host-authored summary or handoff note @@ -69,7 +72,7 @@ public Checkpoint() /// /// Host-defined checkpoint metadata /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } /// /// ISO 8601 UTC timestamp when the checkpoint was created @@ -93,6 +96,7 @@ public Checkpoint() /// The loaded Checkpoint instance. public static Checkpoint Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -105,17 +109,17 @@ public static Checkpoint Load(Dictionary data, LoadContext? con if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("checkpointNumber", out var checkpointNumberValue) && checkpointNumberValue is not null) @@ -125,12 +129,12 @@ public static Checkpoint Load(Dictionary data, LoadContext? con if (data.TryGetValue("title", out var titleValue) && titleValue is not null) { - instance.Title = titleValue?.ToString()!; + instance.Title = titleValue.ToString()!; } if (data.TryGetValue("overview", out var overviewValue) && overviewValue is not null) { - instance.Overview = overviewValue?.ToString()!; + instance.Overview = overviewValue.ToString()!; } if (data.TryGetValue("state", out var stateValue) && stateValue is not null) @@ -140,7 +144,7 @@ public static Checkpoint Load(Dictionary data, LoadContext? con if (data.TryGetValue("summary", out var summaryValue) && summaryValue is not null) { - instance.Summary = summaryValue?.ToString()!; + instance.Summary = summaryValue.ToString()!; } if (data.TryGetValue("metadata", out var metadataValue) && metadataValue is not null) @@ -150,12 +154,12 @@ public static Checkpoint Load(Dictionary data, LoadContext? con if (data.TryGetValue("createdAt", out var createdAtValue) && createdAtValue is not null) { - instance.CreatedAt = createdAtValue?.ToString()!; + instance.CreatedAt = createdAtValue.ToString()!; } if (data.TryGetValue("redaction", out var redactionValue) && redactionValue is not null) { - instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context); + instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context!.At("redaction")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/CompactionCompletePayload.cs b/runtime/csharp/Prompty.Core/Model/events/CompactionCompletePayload.cs index 58226e29d..6cdd121ec 100644 --- a/runtime/csharp/Prompty.Core/Model/events/CompactionCompletePayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/CompactionCompletePayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public CompactionCompletePayload() /// The loaded CompactionCompletePayload instance. public static CompactionCompletePayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); diff --git a/runtime/csharp/Prompty.Core/Model/events/CompactionFailedPayload.cs b/runtime/csharp/Prompty.Core/Model/events/CompactionFailedPayload.cs index 7afec04b4..06c252b52 100644 --- a/runtime/csharp/Prompty.Core/Model/events/CompactionFailedPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/CompactionFailedPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -43,6 +46,7 @@ public CompactionFailedPayload() /// The loaded CompactionFailedPayload instance. public static CompactionFailedPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -55,7 +59,7 @@ public static CompactionFailedPayload Load(Dictionary data, Loa if (data.TryGetValue("message", out var messageValue) && messageValue is not null) { - instance.Message = messageValue?.ToString()!; + instance.Message = messageValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/CompactionStartPayload.cs b/runtime/csharp/Prompty.Core/Model/events/CompactionStartPayload.cs index 97631ba53..acc93c97f 100644 --- a/runtime/csharp/Prompty.Core/Model/events/CompactionStartPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/CompactionStartPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -43,6 +46,7 @@ public CompactionStartPayload() /// The loaded CompactionStartPayload instance. public static CompactionStartPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); diff --git a/runtime/csharp/Prompty.Core/Model/events/DoneEventPayload.cs b/runtime/csharp/Prompty.Core/Model/events/DoneEventPayload.cs index 3640a80c1..4345183a3 100644 --- a/runtime/csharp/Prompty.Core/Model/events/DoneEventPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/DoneEventPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,6 +51,7 @@ public DoneEventPayload() /// The loaded DoneEventPayload instance. public static DoneEventPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,7 +69,7 @@ public static DoneEventPayload Load(Dictionary data, LoadContex if (data.TryGetValue("messages", out var messagesValue) && messagesValue is not null) { - instance.Messages = LoadMessages(messagesValue, context); + instance.Messages = LoadMessages(messagesValue, context!.At("messages")); } if (context is not null) @@ -83,46 +87,49 @@ public static IList LoadMessages(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'messages' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "messages" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'messages' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["role"] = kvp.Value }; - result.Add(Message.Load(newDict, context)); + result.Add(Message.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Message.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/events/ErrorChunk.cs b/runtime/csharp/Prompty.Core/Model/events/ErrorChunk.cs index 2982e77f6..31a20a6f8 100644 --- a/runtime/csharp/Prompty.Core/Model/events/ErrorChunk.cs +++ b/runtime/csharp/Prompty.Core/Model/events/ErrorChunk.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,6 +51,7 @@ public ErrorChunk() /// The loaded ErrorChunk instance. public new static ErrorChunk Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -60,12 +64,12 @@ public ErrorChunk() if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("message", out var messageValue) && messageValue is not null) { - instance.Message = messageValue?.ToString()!; + instance.Message = messageValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/ErrorEventPayload.cs b/runtime/csharp/Prompty.Core/Model/events/ErrorEventPayload.cs index 72210e052..7261a7d1f 100644 --- a/runtime/csharp/Prompty.Core/Model/events/ErrorEventPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/ErrorEventPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public ErrorEventPayload() /// The loaded ErrorEventPayload instance. public static ErrorEventPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,17 +69,17 @@ public static ErrorEventPayload Load(Dictionary data, LoadConte if (data.TryGetValue("message", out var messageValue) && messageValue is not null) { - instance.Message = messageValue?.ToString()!; + instance.Message = messageValue.ToString()!; } if (data.TryGetValue("errorKind", out var errorKindValue) && errorKindValue is not null) { - instance.ErrorKind = errorKindValue?.ToString()!; + instance.ErrorKind = errorKindValue.ToString()!; } if (data.TryGetValue("phase", out var phaseValue) && phaseValue is not null) { - instance.Phase = phaseValue?.ToString()!; + instance.Phase = phaseValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/HarnessContext.cs b/runtime/csharp/Prompty.Core/Model/events/HarnessContext.cs index e406aa2d0..929cb0793 100644 --- a/runtime/csharp/Prompty.Core/Model/events/HarnessContext.cs +++ b/runtime/csharp/Prompty.Core/Model/events/HarnessContext.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -43,7 +46,7 @@ public HarnessContext() /// /// Host-defined context metadata, such as source-control or sandbox details /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } @@ -57,6 +60,7 @@ public HarnessContext() /// The loaded HarnessContext instance. public static HarnessContext Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -69,12 +73,12 @@ public static HarnessContext Load(Dictionary data, LoadContext? if (data.TryGetValue("cwd", out var cwdValue) && cwdValue is not null) { - instance.Cwd = cwdValue?.ToString()!; + instance.Cwd = cwdValue.ToString()!; } if (data.TryGetValue("gitRoot", out var gitRootValue) && gitRootValue is not null) { - instance.GitRoot = gitRootValue?.ToString()!; + instance.GitRoot = gitRootValue.ToString()!; } if (data.TryGetValue("metadata", out var metadataValue) && metadataValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/HookEndPayload.cs b/runtime/csharp/Prompty.Core/Model/events/HookEndPayload.cs index 8df9fcc1a..8908f8ba5 100644 --- a/runtime/csharp/Prompty.Core/Model/events/HookEndPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/HookEndPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -49,7 +52,7 @@ public HookEndPayload() /// /// Hook output after host-side sanitization /// - public IDictionary? Output { get; set; } + public IDictionary? Output { get; set; } /// /// Hook execution duration in milliseconds @@ -78,6 +81,7 @@ public HookEndPayload() /// The loaded HookEndPayload instance. public static HookEndPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -90,17 +94,17 @@ public static HookEndPayload Load(Dictionary data, LoadContext? if (data.TryGetValue("hookInvocationId", out var hookInvocationIdValue) && hookInvocationIdValue is not null) { - instance.HookInvocationId = hookInvocationIdValue?.ToString()!; + instance.HookInvocationId = hookInvocationIdValue.ToString()!; } if (data.TryGetValue("hookType", out var hookTypeValue) && hookTypeValue is not null) { - instance.HookType = hookTypeValue?.ToString()!; + instance.HookType = hookTypeValue.ToString()!; } if (data.TryGetValue("scope", out var scopeValue) && scopeValue is not null) { - instance.Scope = HookEndScopeParser.Parse(scopeValue?.ToString()!); + instance.Scope = HookEndScopeParser.Parse(scopeValue.ToString()!); } if (data.TryGetValue("success", out var successValue) && successValue is not null) @@ -120,12 +124,12 @@ public static HookEndPayload Load(Dictionary data, LoadContext? if (data.TryGetValue("error", out var errorValue) && errorValue is not null) { - instance.Error = errorValue?.ToString()!; + instance.Error = errorValue.ToString()!; } if (data.TryGetValue("redaction", out var redactionValue) && redactionValue is not null) { - instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context); + instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context!.At("redaction")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/HookEndScope.cs b/runtime/csharp/Prompty.Core/Model/events/HookEndScope.cs index 7426d2619..d07b77219 100644 --- a/runtime/csharp/Prompty.Core/Model/events/HookEndScope.cs +++ b/runtime/csharp/Prompty.Core/Model/events/HookEndScope.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/events/HookStartPayload.cs b/runtime/csharp/Prompty.Core/Model/events/HookStartPayload.cs index dc1e5310a..15dc8f6ae 100644 --- a/runtime/csharp/Prompty.Core/Model/events/HookStartPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/HookStartPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -44,7 +47,7 @@ public HookStartPayload() /// /// Hook input after host-side sanitization /// - public IDictionary? Input { get; set; } + public IDictionary? Input { get; set; } /// /// Redaction state for sensitive hook input fields @@ -63,6 +66,7 @@ public HookStartPayload() /// The loaded HookStartPayload instance. public static HookStartPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -75,17 +79,17 @@ public static HookStartPayload Load(Dictionary data, LoadContex if (data.TryGetValue("hookInvocationId", out var hookInvocationIdValue) && hookInvocationIdValue is not null) { - instance.HookInvocationId = hookInvocationIdValue?.ToString()!; + instance.HookInvocationId = hookInvocationIdValue.ToString()!; } if (data.TryGetValue("hookType", out var hookTypeValue) && hookTypeValue is not null) { - instance.HookType = hookTypeValue?.ToString()!; + instance.HookType = hookTypeValue.ToString()!; } if (data.TryGetValue("scope", out var scopeValue) && scopeValue is not null) { - instance.Scope = HookStartScopeParser.Parse(scopeValue?.ToString()!); + instance.Scope = HookStartScopeParser.Parse(scopeValue.ToString()!); } if (data.TryGetValue("input", out var inputValue) && inputValue is not null) @@ -95,7 +99,7 @@ public static HookStartPayload Load(Dictionary data, LoadContex if (data.TryGetValue("redaction", out var redactionValue) && redactionValue is not null) { - instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context); + instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context!.At("redaction")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/HookStartScope.cs b/runtime/csharp/Prompty.Core/Model/events/HookStartScope.cs index b0d7ae91b..673ac1a4d 100644 --- a/runtime/csharp/Prompty.Core/Model/events/HookStartScope.cs +++ b/runtime/csharp/Prompty.Core/Model/events/HookStartScope.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/events/HostToolRequest.cs b/runtime/csharp/Prompty.Core/Model/events/HostToolRequest.cs index c487f6090..ecbaecad7 100644 --- a/runtime/csharp/Prompty.Core/Model/events/HostToolRequest.cs +++ b/runtime/csharp/Prompty.Core/Model/events/HostToolRequest.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -42,9 +45,9 @@ public HostToolRequest() public string ToolName { get; set; } = string.Empty; /// - /// Tool arguments after host-side sanitization + /// Tool arguments after host-side sanitization. Values may be explicit null. /// - public IDictionary? Arguments { get; set; } + public IDictionary? Arguments { get; set; } /// /// Working directory or execution scope for the tool @@ -63,6 +66,7 @@ public HostToolRequest() /// The loaded HostToolRequest instance. public static HostToolRequest Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -75,17 +79,17 @@ public static HostToolRequest Load(Dictionary data, LoadContext if (data.TryGetValue("requestId", out var requestIdValue) && requestIdValue is not null) { - instance.RequestId = requestIdValue?.ToString()!; + instance.RequestId = requestIdValue.ToString()!; } if (data.TryGetValue("toolCallId", out var toolCallIdValue) && toolCallIdValue is not null) { - instance.ToolCallId = toolCallIdValue?.ToString()!; + instance.ToolCallId = toolCallIdValue.ToString()!; } if (data.TryGetValue("toolName", out var toolNameValue) && toolNameValue is not null) { - instance.ToolName = toolNameValue?.ToString()!; + instance.ToolName = toolNameValue.ToString()!; } if (data.TryGetValue("arguments", out var argumentsValue) && argumentsValue is not null) @@ -95,7 +99,7 @@ public static HostToolRequest Load(Dictionary data, LoadContext if (data.TryGetValue("workingDirectory", out var workingDirectoryValue) && workingDirectoryValue is not null) { - instance.WorkingDirectory = workingDirectoryValue?.ToString()!; + instance.WorkingDirectory = workingDirectoryValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/HostToolResult.cs b/runtime/csharp/Prompty.Core/Model/events/HostToolResult.cs index d909c5e98..f8fda26b5 100644 --- a/runtime/csharp/Prompty.Core/Model/events/HostToolResult.cs +++ b/runtime/csharp/Prompty.Core/Model/events/HostToolResult.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -69,7 +72,7 @@ public HostToolResult() /// /// Host-specific telemetry for the execution /// - public IDictionary? Telemetry { get; set; } + public IDictionary? Telemetry { get; set; } @@ -83,6 +86,7 @@ public HostToolResult() /// The loaded HostToolResult instance. public static HostToolResult Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -95,17 +99,17 @@ public static HostToolResult Load(Dictionary data, LoadContext? if (data.TryGetValue("requestId", out var requestIdValue) && requestIdValue is not null) { - instance.RequestId = requestIdValue?.ToString()!; + instance.RequestId = requestIdValue.ToString()!; } if (data.TryGetValue("toolCallId", out var toolCallIdValue) && toolCallIdValue is not null) { - instance.ToolCallId = toolCallIdValue?.ToString()!; + instance.ToolCallId = toolCallIdValue.ToString()!; } if (data.TryGetValue("toolName", out var toolNameValue) && toolNameValue is not null) { - instance.ToolName = toolNameValue?.ToString()!; + instance.ToolName = toolNameValue.ToString()!; } if (data.TryGetValue("success", out var successValue) && successValue is not null) @@ -130,7 +134,7 @@ public static HostToolResult Load(Dictionary data, LoadContext? if (data.TryGetValue("errorKind", out var errorKindValue) && errorKindValue is not null) { - instance.ErrorKind = errorKindValue?.ToString()!; + instance.ErrorKind = errorKindValue.ToString()!; } if (data.TryGetValue("telemetry", out var telemetryValue) && telemetryValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/LlmCompletePayload.cs b/runtime/csharp/Prompty.Core/Model/events/LlmCompletePayload.cs index 7465de8d9..a8b684f5e 100644 --- a/runtime/csharp/Prompty.Core/Model/events/LlmCompletePayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/LlmCompletePayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -58,6 +61,7 @@ public LlmCompletePayload() /// The loaded LlmCompletePayload instance. public static LlmCompletePayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -70,17 +74,17 @@ public static LlmCompletePayload Load(Dictionary data, LoadCont if (data.TryGetValue("requestId", out var requestIdValue) && requestIdValue is not null) { - instance.RequestId = requestIdValue?.ToString()!; + instance.RequestId = requestIdValue.ToString()!; } if (data.TryGetValue("serviceRequestId", out var serviceRequestIdValue) && serviceRequestIdValue is not null) { - instance.ServiceRequestId = serviceRequestIdValue?.ToString()!; + instance.ServiceRequestId = serviceRequestIdValue.ToString()!; } if (data.TryGetValue("usage", out var usageValue) && usageValue is not null) { - instance.Usage = TokenUsage.Load(usageValue.GetDictionary(TokenUsage.ShorthandProperty), context); + instance.Usage = TokenUsage.Load(usageValue.GetDictionary(TokenUsage.ShorthandProperty), context!.At("usage")); } if (data.TryGetValue("durationMs", out var durationMsValue) && durationMsValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/LlmStartPayload.cs b/runtime/csharp/Prompty.Core/Model/events/LlmStartPayload.cs index 7fb7b2060..3237bc514 100644 --- a/runtime/csharp/Prompty.Core/Model/events/LlmStartPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/LlmStartPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -58,6 +61,7 @@ public LlmStartPayload() /// The loaded LlmStartPayload instance. public static LlmStartPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -70,12 +74,12 @@ public static LlmStartPayload Load(Dictionary data, LoadContext if (data.TryGetValue("provider", out var providerValue) && providerValue is not null) { - instance.Provider = providerValue?.ToString()!; + instance.Provider = providerValue.ToString()!; } if (data.TryGetValue("modelId", out var modelIdValue) && modelIdValue is not null) { - instance.ModelId = modelIdValue?.ToString()!; + instance.ModelId = modelIdValue.ToString()!; } if (data.TryGetValue("messageCount", out var messageCountValue) && messageCountValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/MessagesUpdatedPayload.cs b/runtime/csharp/Prompty.Core/Model/events/MessagesUpdatedPayload.cs index a1266b6cd..2b8d1daa1 100644 --- a/runtime/csharp/Prompty.Core/Model/events/MessagesUpdatedPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/MessagesUpdatedPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -58,6 +61,7 @@ public MessagesUpdatedPayload() /// The loaded MessagesUpdatedPayload instance. public static MessagesUpdatedPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -70,17 +74,17 @@ public static MessagesUpdatedPayload Load(Dictionary data, Load if (data.TryGetValue("messages", out var messagesValue) && messagesValue is not null) { - instance.Messages = LoadMessages(messagesValue, context); + instance.Messages = LoadMessages(messagesValue, context!.At("messages")); } if (data.TryGetValue("reason", out var reasonValue) && reasonValue is not null) { - instance.Reason = reasonValue?.ToString()!; + instance.Reason = reasonValue.ToString()!; } if (data.TryGetValue("appended", out var appendedValue) && appendedValue is not null) { - instance.Appended = LoadAppended(appendedValue, context); + instance.Appended = LoadAppended(appendedValue, context!.At("appended")); } if (data.TryGetValue("removed", out var removedValue) && removedValue is not null) @@ -103,46 +107,49 @@ public static IList LoadMessages(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'messages' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "messages" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'messages' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["role"] = kvp.Value }; - result.Add(Message.Load(newDict, context)); + result.Add(Message.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Message.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -157,46 +164,49 @@ public static IList LoadAppended(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'appended' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "appended" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'appended' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["role"] = kvp.Value }; - result.Add(Message.Load(newDict, context)); + result.Add(Message.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Message.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/events/PermissionCompletedPayload.cs b/runtime/csharp/Prompty.Core/Model/events/PermissionCompletedPayload.cs index 3793084ea..ffec7d6d2 100644 --- a/runtime/csharp/Prompty.Core/Model/events/PermissionCompletedPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/PermissionCompletedPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -54,7 +57,7 @@ public PermissionCompletedPayload() /// /// Host-specific decision result, such as a durable approval token or denial details /// - public IDictionary? Result { get; set; } + public IDictionary? Result { get; set; } /// /// Redaction state for sensitive decision fields @@ -73,6 +76,7 @@ public PermissionCompletedPayload() /// The loaded PermissionCompletedPayload instance. public static PermissionCompletedPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -85,17 +89,17 @@ public static PermissionCompletedPayload Load(Dictionary data, if (data.TryGetValue("requestId", out var requestIdValue) && requestIdValue is not null) { - instance.RequestId = requestIdValue?.ToString()!; + instance.RequestId = requestIdValue.ToString()!; } if (data.TryGetValue("toolCallId", out var toolCallIdValue) && toolCallIdValue is not null) { - instance.ToolCallId = toolCallIdValue?.ToString()!; + instance.ToolCallId = toolCallIdValue.ToString()!; } if (data.TryGetValue("permission", out var permissionValue) && permissionValue is not null) { - instance.Permission = permissionValue?.ToString()!; + instance.Permission = permissionValue.ToString()!; } if (data.TryGetValue("approved", out var approvedValue) && approvedValue is not null) @@ -105,7 +109,7 @@ public static PermissionCompletedPayload Load(Dictionary data, if (data.TryGetValue("reason", out var reasonValue) && reasonValue is not null) { - instance.Reason = reasonValue?.ToString()!; + instance.Reason = reasonValue.ToString()!; } if (data.TryGetValue("result", out var resultValue) && resultValue is not null) @@ -115,7 +119,7 @@ public static PermissionCompletedPayload Load(Dictionary data, if (data.TryGetValue("redaction", out var redactionValue) && redactionValue is not null) { - instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context); + instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context!.At("redaction")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/PermissionDecision.cs b/runtime/csharp/Prompty.Core/Model/events/PermissionDecision.cs index 74b6d83bd..e8463ae2e 100644 --- a/runtime/csharp/Prompty.Core/Model/events/PermissionDecision.cs +++ b/runtime/csharp/Prompty.Core/Model/events/PermissionDecision.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -54,7 +57,7 @@ public PermissionDecision() /// /// Host-specific decision result, such as a durable approval token or denial details /// - public IDictionary? Result { get; set; } + public IDictionary? Result { get; set; } @@ -68,6 +71,7 @@ public PermissionDecision() /// The loaded PermissionDecision instance. public static PermissionDecision Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -80,17 +84,17 @@ public static PermissionDecision Load(Dictionary data, LoadCont if (data.TryGetValue("requestId", out var requestIdValue) && requestIdValue is not null) { - instance.RequestId = requestIdValue?.ToString()!; + instance.RequestId = requestIdValue.ToString()!; } if (data.TryGetValue("toolCallId", out var toolCallIdValue) && toolCallIdValue is not null) { - instance.ToolCallId = toolCallIdValue?.ToString()!; + instance.ToolCallId = toolCallIdValue.ToString()!; } if (data.TryGetValue("permission", out var permissionValue) && permissionValue is not null) { - instance.Permission = permissionValue?.ToString()!; + instance.Permission = permissionValue.ToString()!; } if (data.TryGetValue("approved", out var approvedValue) && approvedValue is not null) @@ -100,7 +104,7 @@ public static PermissionDecision Load(Dictionary data, LoadCont if (data.TryGetValue("reason", out var reasonValue) && reasonValue is not null) { - instance.Reason = reasonValue?.ToString()!; + instance.Reason = reasonValue.ToString()!; } if (data.TryGetValue("result", out var resultValue) && resultValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/PermissionRequest.cs b/runtime/csharp/Prompty.Core/Model/events/PermissionRequest.cs index 6cf407a09..d1257694d 100644 --- a/runtime/csharp/Prompty.Core/Model/events/PermissionRequest.cs +++ b/runtime/csharp/Prompty.Core/Model/events/PermissionRequest.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -51,7 +54,7 @@ public PermissionRequest() /// /// Additional host-specific permission details /// - public IDictionary? Details { get; set; } + public IDictionary? Details { get; set; } /// /// Human-readable prompt or rationale that can be shown to an approval UI @@ -61,7 +64,7 @@ public PermissionRequest() /// /// Policy metadata used to evaluate or explain the permission request /// - public IDictionary? Policy { get; set; } + public IDictionary? Policy { get; set; } @@ -75,6 +78,7 @@ public PermissionRequest() /// The loaded PermissionRequest instance. public static PermissionRequest Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -87,22 +91,22 @@ public static PermissionRequest Load(Dictionary data, LoadConte if (data.TryGetValue("requestId", out var requestIdValue) && requestIdValue is not null) { - instance.RequestId = requestIdValue?.ToString()!; + instance.RequestId = requestIdValue.ToString()!; } if (data.TryGetValue("toolCallId", out var toolCallIdValue) && toolCallIdValue is not null) { - instance.ToolCallId = toolCallIdValue?.ToString()!; + instance.ToolCallId = toolCallIdValue.ToString()!; } if (data.TryGetValue("permission", out var permissionValue) && permissionValue is not null) { - instance.Permission = permissionValue?.ToString()!; + instance.Permission = permissionValue.ToString()!; } if (data.TryGetValue("target", out var targetValue) && targetValue is not null) { - instance.Target = targetValue?.ToString()!; + instance.Target = targetValue.ToString()!; } if (data.TryGetValue("details", out var detailsValue) && detailsValue is not null) @@ -112,7 +116,7 @@ public static PermissionRequest Load(Dictionary data, LoadConte if (data.TryGetValue("promptRequest", out var promptRequestValue) && promptRequestValue is not null) { - instance.PromptRequest = promptRequestValue?.ToString()!; + instance.PromptRequest = promptRequestValue.ToString()!; } if (data.TryGetValue("policy", out var policyValue) && policyValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/PermissionRequestedPayload.cs b/runtime/csharp/Prompty.Core/Model/events/PermissionRequestedPayload.cs index 55b927cc6..8bea7a6d3 100644 --- a/runtime/csharp/Prompty.Core/Model/events/PermissionRequestedPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/PermissionRequestedPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -49,7 +52,7 @@ public PermissionRequestedPayload() /// /// Additional host-specific permission details /// - public IDictionary? Details { get; set; } + public IDictionary? Details { get; set; } /// /// Human-readable prompt or rationale that can be shown to an approval UI @@ -59,7 +62,7 @@ public PermissionRequestedPayload() /// /// Policy metadata used to evaluate or explain the permission request /// - public IDictionary? Policy { get; set; } + public IDictionary? Policy { get; set; } /// /// Redaction state for sensitive request fields @@ -78,6 +81,7 @@ public PermissionRequestedPayload() /// The loaded PermissionRequestedPayload instance. public static PermissionRequestedPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -90,22 +94,22 @@ public static PermissionRequestedPayload Load(Dictionary data, if (data.TryGetValue("requestId", out var requestIdValue) && requestIdValue is not null) { - instance.RequestId = requestIdValue?.ToString()!; + instance.RequestId = requestIdValue.ToString()!; } if (data.TryGetValue("toolCallId", out var toolCallIdValue) && toolCallIdValue is not null) { - instance.ToolCallId = toolCallIdValue?.ToString()!; + instance.ToolCallId = toolCallIdValue.ToString()!; } if (data.TryGetValue("permission", out var permissionValue) && permissionValue is not null) { - instance.Permission = permissionValue?.ToString()!; + instance.Permission = permissionValue.ToString()!; } if (data.TryGetValue("target", out var targetValue) && targetValue is not null) { - instance.Target = targetValue?.ToString()!; + instance.Target = targetValue.ToString()!; } if (data.TryGetValue("details", out var detailsValue) && detailsValue is not null) @@ -115,7 +119,7 @@ public static PermissionRequestedPayload Load(Dictionary data, if (data.TryGetValue("promptRequest", out var promptRequestValue) && promptRequestValue is not null) { - instance.PromptRequest = promptRequestValue?.ToString()!; + instance.PromptRequest = promptRequestValue.ToString()!; } if (data.TryGetValue("policy", out var policyValue) && policyValue is not null) @@ -125,7 +129,7 @@ public static PermissionRequestedPayload Load(Dictionary data, if (data.TryGetValue("redaction", out var redactionValue) && redactionValue is not null) { - instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context); + instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context!.At("redaction")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/RedactedField.cs b/runtime/csharp/Prompty.Core/Model/events/RedactedField.cs index a3be82664..53aefb73b 100644 --- a/runtime/csharp/Prompty.Core/Model/events/RedactedField.cs +++ b/runtime/csharp/Prompty.Core/Model/events/RedactedField.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public RedactedField() /// The loaded RedactedField instance. public static RedactedField Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,17 +69,17 @@ public static RedactedField Load(Dictionary data, LoadContext? if (data.TryGetValue("path", out var pathValue) && pathValue is not null) { - instance.Path = pathValue?.ToString()!; + instance.Path = pathValue.ToString()!; } if (data.TryGetValue("mode", out var modeValue) && modeValue is not null) { - instance.Mode = RedactionModeParser.Parse(modeValue?.ToString()!); + instance.Mode = RedactionModeParser.Parse(modeValue.ToString()!); } if (data.TryGetValue("reason", out var reasonValue) && reasonValue is not null) { - instance.Reason = reasonValue?.ToString()!; + instance.Reason = reasonValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/RedactionMetadata.cs b/runtime/csharp/Prompty.Core/Model/events/RedactionMetadata.cs index c43813a3a..160fe6e38 100644 --- a/runtime/csharp/Prompty.Core/Model/events/RedactionMetadata.cs +++ b/runtime/csharp/Prompty.Core/Model/events/RedactionMetadata.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public RedactionMetadata() /// The loaded RedactionMetadata instance. public static RedactionMetadata Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -70,12 +74,12 @@ public static RedactionMetadata Load(Dictionary data, LoadConte if (data.TryGetValue("fields", out var fieldsValue) && fieldsValue is not null) { - instance.Fields = LoadFields(fieldsValue, context); + instance.Fields = LoadFields(fieldsValue, context!.At("fields")); } if (data.TryGetValue("policy", out var policyValue) && policyValue is not null) { - instance.Policy = policyValue?.ToString()!; + instance.Policy = policyValue.ToString()!; } if (context is not null) @@ -93,46 +97,49 @@ public static IList LoadFields(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'fields' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "fields" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'fields' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(RedactedField.Load(itemDict, context)); + result.Add(RedactedField.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["path"] = kvp.Value }; - result.Add(RedactedField.Load(newDict, context)); + result.Add(RedactedField.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(RedactedField.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(RedactedField.Load(itemDict, context)); + result.Add(RedactedField.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/events/RedactionMode.cs b/runtime/csharp/Prompty.Core/Model/events/RedactionMode.cs index c3a3e2099..860256867 100644 --- a/runtime/csharp/Prompty.Core/Model/events/RedactionMode.cs +++ b/runtime/csharp/Prompty.Core/Model/events/RedactionMode.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/events/RetryPayload.cs b/runtime/csharp/Prompty.Core/Model/events/RetryPayload.cs index bbdc575e7..87575b6f3 100644 --- a/runtime/csharp/Prompty.Core/Model/events/RetryPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/RetryPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -63,6 +66,7 @@ public RetryPayload() /// The loaded RetryPayload instance. public static RetryPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -75,7 +79,7 @@ public static RetryPayload Load(Dictionary data, LoadContext? c if (data.TryGetValue("operation", out var operationValue) && operationValue is not null) { - instance.Operation = operationValue?.ToString()!; + instance.Operation = operationValue.ToString()!; } if (data.TryGetValue("attempt", out var attemptValue) && attemptValue is not null) @@ -95,7 +99,7 @@ public static RetryPayload Load(Dictionary data, LoadContext? c if (data.TryGetValue("reason", out var reasonValue) && reasonValue is not null) { - instance.Reason = reasonValue?.ToString()!; + instance.Reason = reasonValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/SessionEndPayload.cs b/runtime/csharp/Prompty.Core/Model/events/SessionEndPayload.cs index b40fe7221..8fd21e3cd 100644 --- a/runtime/csharp/Prompty.Core/Model/events/SessionEndPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/SessionEndPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -58,6 +61,7 @@ public SessionEndPayload() /// The loaded SessionEndPayload instance. public static SessionEndPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -70,17 +74,17 @@ public static SessionEndPayload Load(Dictionary data, LoadConte if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("status", out var statusValue) && statusValue is not null) { - instance.Status = SessionEndStatusParser.Parse(statusValue?.ToString()!); + instance.Status = SessionEndStatusParser.Parse(statusValue.ToString()!); } if (data.TryGetValue("reason", out var reasonValue) && reasonValue is not null) { - instance.Reason = reasonValue?.ToString()!; + instance.Reason = reasonValue.ToString()!; } if (data.TryGetValue("durationMs", out var durationMsValue) && durationMsValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/SessionEndStatus.cs b/runtime/csharp/Prompty.Core/Model/events/SessionEndStatus.cs index e86b6824f..db08af813 100644 --- a/runtime/csharp/Prompty.Core/Model/events/SessionEndStatus.cs +++ b/runtime/csharp/Prompty.Core/Model/events/SessionEndStatus.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/events/SessionEvent.cs b/runtime/csharp/Prompty.Core/Model/events/SessionEvent.cs index bffc35ad7..356588532 100644 --- a/runtime/csharp/Prompty.Core/Model/events/SessionEvent.cs +++ b/runtime/csharp/Prompty.Core/Model/events/SessionEvent.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -62,9 +65,9 @@ public SessionEvent() public string? SpanId { get; set; } /// - /// Event-specific payload. Use the typed payload model matching 'type'. + /// Event-specific payload. Values may be explicit null. Use the typed payload model matching 'type'. /// - public IDictionary Payload { get; set; } = new Dictionary(); + public IDictionary Payload { get; set; } = new Dictionary(); /// /// Redaction state for sensitive payload fields @@ -83,6 +86,7 @@ public SessionEvent() /// The loaded SessionEvent instance. public static SessionEvent Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -95,37 +99,37 @@ public static SessionEvent Load(Dictionary data, LoadContext? c if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("type", out var typeValue) && typeValue is not null) { - instance.Type = SessionEventTypeParser.Parse(typeValue?.ToString()!); + instance.Type = SessionEventTypeParser.Parse(typeValue.ToString()!); } if (data.TryGetValue("timestamp", out var timestampValue) && timestampValue is not null) { - instance.Timestamp = timestampValue?.ToString()!; + instance.Timestamp = timestampValue.ToString()!; } if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("parentId", out var parentIdValue) && parentIdValue is not null) { - instance.ParentId = parentIdValue?.ToString()!; + instance.ParentId = parentIdValue.ToString()!; } if (data.TryGetValue("spanId", out var spanIdValue) && spanIdValue is not null) { - instance.SpanId = spanIdValue?.ToString()!; + instance.SpanId = spanIdValue.ToString()!; } if (data.TryGetValue("payload", out var payloadValue) && payloadValue is not null) @@ -135,7 +139,7 @@ public static SessionEvent Load(Dictionary data, LoadContext? c if (data.TryGetValue("redaction", out var redactionValue) && redactionValue is not null) { - instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context); + instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context!.At("redaction")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/SessionEventType.cs b/runtime/csharp/Prompty.Core/Model/events/SessionEventType.cs index 3cc2b1769..7cac14bc2 100644 --- a/runtime/csharp/Prompty.Core/Model/events/SessionEventType.cs +++ b/runtime/csharp/Prompty.Core/Model/events/SessionEventType.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/events/SessionFileRef.cs b/runtime/csharp/Prompty.Core/Model/events/SessionFileRef.cs index 0dcf68fe0..54b2d5da0 100644 --- a/runtime/csharp/Prompty.Core/Model/events/SessionFileRef.cs +++ b/runtime/csharp/Prompty.Core/Model/events/SessionFileRef.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -63,6 +66,7 @@ public SessionFileRef() /// The loaded SessionFileRef instance. public static SessionFileRef Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -75,17 +79,17 @@ public static SessionFileRef Load(Dictionary data, LoadContext? if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("path", out var pathValue) && pathValue is not null) { - instance.Path = pathValue?.ToString()!; + instance.Path = pathValue.ToString()!; } if (data.TryGetValue("toolName", out var toolNameValue) && toolNameValue is not null) { - instance.ToolName = toolNameValue?.ToString()!; + instance.ToolName = toolNameValue.ToString()!; } if (data.TryGetValue("turnIndex", out var turnIndexValue) && turnIndexValue is not null) @@ -95,7 +99,7 @@ public static SessionFileRef Load(Dictionary data, LoadContext? if (data.TryGetValue("firstSeenAt", out var firstSeenAtValue) && firstSeenAtValue is not null) { - instance.FirstSeenAt = firstSeenAtValue?.ToString()!; + instance.FirstSeenAt = firstSeenAtValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/SessionRef.cs b/runtime/csharp/Prompty.Core/Model/events/SessionRef.cs index 8468fb5d6..d1c20ea2b 100644 --- a/runtime/csharp/Prompty.Core/Model/events/SessionRef.cs +++ b/runtime/csharp/Prompty.Core/Model/events/SessionRef.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -63,6 +66,7 @@ public SessionRef() /// The loaded SessionRef instance. public static SessionRef Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -75,17 +79,17 @@ public static SessionRef Load(Dictionary data, LoadContext? con if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("refType", out var refTypeValue) && refTypeValue is not null) { - instance.RefType = refTypeValue?.ToString()!; + instance.RefType = refTypeValue.ToString()!; } if (data.TryGetValue("refValue", out var refValueValue) && refValueValue is not null) { - instance.RefValue = refValueValue?.ToString()!; + instance.RefValue = refValueValue.ToString()!; } if (data.TryGetValue("turnIndex", out var turnIndexValue) && turnIndexValue is not null) @@ -95,7 +99,7 @@ public static SessionRef Load(Dictionary data, LoadContext? con if (data.TryGetValue("createdAt", out var createdAtValue) && createdAtValue is not null) { - instance.CreatedAt = createdAtValue?.ToString()!; + instance.CreatedAt = createdAtValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/SessionStartPayload.cs b/runtime/csharp/Prompty.Core/Model/events/SessionStartPayload.cs index c42705147..08a2d7b39 100644 --- a/runtime/csharp/Prompty.Core/Model/events/SessionStartPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/SessionStartPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -83,6 +86,7 @@ public SessionStartPayload() /// The loaded SessionStartPayload instance. public static SessionStartPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -95,47 +99,47 @@ public static SessionStartPayload Load(Dictionary data, LoadCon if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("schemaVersion", out var schemaVersionValue) && schemaVersionValue is not null) { - instance.SchemaVersion = schemaVersionValue?.ToString()!; + instance.SchemaVersion = schemaVersionValue.ToString()!; } if (data.TryGetValue("producer", out var producerValue) && producerValue is not null) { - instance.Producer = producerValue?.ToString()!; + instance.Producer = producerValue.ToString()!; } if (data.TryGetValue("runtime", out var runtimeValue) && runtimeValue is not null) { - instance.Runtime = runtimeValue?.ToString()!; + instance.Runtime = runtimeValue.ToString()!; } if (data.TryGetValue("promptyVersion", out var promptyVersionValue) && promptyVersionValue is not null) { - instance.PromptyVersion = promptyVersionValue?.ToString()!; + instance.PromptyVersion = promptyVersionValue.ToString()!; } if (data.TryGetValue("startTime", out var startTimeValue) && startTimeValue is not null) { - instance.StartTime = startTimeValue?.ToString()!; + instance.StartTime = startTimeValue.ToString()!; } if (data.TryGetValue("selectedModel", out var selectedModelValue) && selectedModelValue is not null) { - instance.SelectedModel = selectedModelValue?.ToString()!; + instance.SelectedModel = selectedModelValue.ToString()!; } if (data.TryGetValue("reasoningEffort", out var reasoningEffortValue) && reasoningEffortValue is not null) { - instance.ReasoningEffort = reasoningEffortValue?.ToString()!; + instance.ReasoningEffort = reasoningEffortValue.ToString()!; } if (data.TryGetValue("context", out var contextValue) && contextValue is not null) { - instance.Context = HarnessContext.Load(contextValue.GetDictionary(HarnessContext.ShorthandProperty), context); + instance.Context = HarnessContext.Load(contextValue.GetDictionary(HarnessContext.ShorthandProperty), context!.At("context")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/SessionSummary.cs b/runtime/csharp/Prompty.Core/Model/events/SessionSummary.cs index 4fc0525c4..9a2e308a6 100644 --- a/runtime/csharp/Prompty.Core/Model/events/SessionSummary.cs +++ b/runtime/csharp/Prompty.Core/Model/events/SessionSummary.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -68,6 +71,7 @@ public SessionSummary() /// The loaded SessionSummary instance. public static SessionSummary Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -80,12 +84,12 @@ public static SessionSummary Load(Dictionary data, LoadContext? if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("status", out var statusValue) && statusValue is not null) { - instance.Status = SessionSummaryStatusParser.Parse(statusValue?.ToString()!); + instance.Status = SessionSummaryStatusParser.Parse(statusValue.ToString()!); } if (data.TryGetValue("turns", out var turnsValue) && turnsValue is not null) @@ -100,7 +104,7 @@ public static SessionSummary Load(Dictionary data, LoadContext? if (data.TryGetValue("usage", out var usageValue) && usageValue is not null) { - instance.Usage = TokenUsage.Load(usageValue.GetDictionary(TokenUsage.ShorthandProperty), context); + instance.Usage = TokenUsage.Load(usageValue.GetDictionary(TokenUsage.ShorthandProperty), context!.At("usage")); } if (data.TryGetValue("durationMs", out var durationMsValue) && durationMsValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/SessionSummaryStatus.cs b/runtime/csharp/Prompty.Core/Model/events/SessionSummaryStatus.cs index c517380fc..9574bbd36 100644 --- a/runtime/csharp/Prompty.Core/Model/events/SessionSummaryStatus.cs +++ b/runtime/csharp/Prompty.Core/Model/events/SessionSummaryStatus.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/events/SessionTrace.cs b/runtime/csharp/Prompty.Core/Model/events/SessionTrace.cs index 2f96a080b..764f8ead5 100644 --- a/runtime/csharp/Prompty.Core/Model/events/SessionTrace.cs +++ b/runtime/csharp/Prompty.Core/Model/events/SessionTrace.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -93,6 +96,7 @@ public SessionTrace() /// The loaded SessionTrace instance. public static SessionTrace Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -105,57 +109,57 @@ public static SessionTrace Load(Dictionary data, LoadContext? c if (data.TryGetValue("version", out var versionValue) && versionValue is not null) { - instance.Version = versionValue?.ToString()!; + instance.Version = versionValue.ToString()!; } if (data.TryGetValue("runtime", out var runtimeValue) && runtimeValue is not null) { - instance.Runtime = runtimeValue?.ToString()!; + instance.Runtime = runtimeValue.ToString()!; } if (data.TryGetValue("promptyVersion", out var promptyVersionValue) && promptyVersionValue is not null) { - instance.PromptyVersion = promptyVersionValue?.ToString()!; + instance.PromptyVersion = promptyVersionValue.ToString()!; } if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("events", out var eventsValue) && eventsValue is not null) { - instance.Events = LoadEvents(eventsValue, context); + instance.Events = LoadEvents(eventsValue, context!.At("events")); } if (data.TryGetValue("turns", out var turnsValue) && turnsValue is not null) { - instance.Turns = LoadTurns(turnsValue, context); + instance.Turns = LoadTurns(turnsValue, context!.At("turns")); } if (data.TryGetValue("checkpoints", out var checkpointsValue) && checkpointsValue is not null) { - instance.Checkpoints = LoadCheckpoints(checkpointsValue, context); + instance.Checkpoints = LoadCheckpoints(checkpointsValue, context!.At("checkpoints")); } if (data.TryGetValue("trajectory", out var trajectoryValue) && trajectoryValue is not null) { - instance.Trajectory = LoadTrajectory(trajectoryValue, context); + instance.Trajectory = LoadTrajectory(trajectoryValue, context!.At("trajectory")); } if (data.TryGetValue("files", out var filesValue) && filesValue is not null) { - instance.Files = LoadFiles(filesValue, context); + instance.Files = LoadFiles(filesValue, context!.At("files")); } if (data.TryGetValue("refs", out var refsValue) && refsValue is not null) { - instance.Refs = LoadRefs(refsValue, context); + instance.Refs = LoadRefs(refsValue, context!.At("refs")); } if (data.TryGetValue("summary", out var summaryValue) && summaryValue is not null) { - instance.Summary = SessionSummary.Load(summaryValue.GetDictionary(SessionSummary.ShorthandProperty), context); + instance.Summary = SessionSummary.Load(summaryValue.GetDictionary(SessionSummary.ShorthandProperty), context!.At("summary")); } if (context is not null) @@ -173,46 +177,49 @@ public static IList LoadEvents(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'events' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "events" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'events' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(SessionEvent.Load(itemDict, context)); + result.Add(SessionEvent.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["id"] = kvp.Value }; - result.Add(SessionEvent.Load(newDict, context)); + result.Add(SessionEvent.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(SessionEvent.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(SessionEvent.Load(itemDict, context)); + result.Add(SessionEvent.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -227,46 +234,49 @@ public static IList LoadTurns(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'turns' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "turns" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'turns' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(TurnTrace.Load(itemDict, context)); + result.Add(TurnTrace.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["version"] = kvp.Value }; - result.Add(TurnTrace.Load(newDict, context)); + result.Add(TurnTrace.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(TurnTrace.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(TurnTrace.Load(itemDict, context)); + result.Add(TurnTrace.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -281,46 +291,49 @@ public static IList LoadCheckpoints(object data, LoadContext? contex { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'checkpoints' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "checkpoints" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'checkpoints' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Checkpoint.Load(itemDict, context)); + result.Add(Checkpoint.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["id"] = kvp.Value }; - result.Add(Checkpoint.Load(newDict, context)); + result.Add(Checkpoint.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Checkpoint.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Checkpoint.Load(itemDict, context)); + result.Add(Checkpoint.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -335,46 +348,49 @@ public static IList LoadTrajectory(object data, LoadContext? co { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'trajectory' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "trajectory" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'trajectory' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(TrajectoryEvent.Load(itemDict, context)); + result.Add(TrajectoryEvent.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["id"] = kvp.Value }; - result.Add(TrajectoryEvent.Load(newDict, context)); + result.Add(TrajectoryEvent.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(TrajectoryEvent.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(TrajectoryEvent.Load(itemDict, context)); + result.Add(TrajectoryEvent.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -389,46 +405,49 @@ public static IList LoadFiles(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'files' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "files" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'files' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(SessionFileRef.Load(itemDict, context)); + result.Add(SessionFileRef.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["sessionId"] = kvp.Value }; - result.Add(SessionFileRef.Load(newDict, context)); + result.Add(SessionFileRef.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(SessionFileRef.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(SessionFileRef.Load(itemDict, context)); + result.Add(SessionFileRef.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -443,46 +462,49 @@ public static IList LoadRefs(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'refs' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "refs" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'refs' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(SessionRef.Load(itemDict, context)); + result.Add(SessionRef.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["sessionId"] = kvp.Value }; - result.Add(SessionRef.Load(newDict, context)); + result.Add(SessionRef.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(SessionRef.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(SessionRef.Load(itemDict, context)); + result.Add(SessionRef.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/events/SessionWarningPayload.cs b/runtime/csharp/Prompty.Core/Model/events/SessionWarningPayload.cs index 55b498da4..0415bc65c 100644 --- a/runtime/csharp/Prompty.Core/Model/events/SessionWarningPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/SessionWarningPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -39,7 +42,7 @@ public SessionWarningPayload() /// /// Additional host-specific warning details /// - public IDictionary? Details { get; set; } + public IDictionary? Details { get; set; } @@ -53,6 +56,7 @@ public SessionWarningPayload() /// The loaded SessionWarningPayload instance. public static SessionWarningPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,12 +69,12 @@ public static SessionWarningPayload Load(Dictionary data, LoadC if (data.TryGetValue("warningType", out var warningTypeValue) && warningTypeValue is not null) { - instance.WarningType = warningTypeValue?.ToString()!; + instance.WarningType = warningTypeValue.ToString()!; } if (data.TryGetValue("message", out var messageValue) && messageValue is not null) { - instance.Message = messageValue?.ToString()!; + instance.Message = messageValue.ToString()!; } if (data.TryGetValue("details", out var detailsValue) && detailsValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/StatusEventPayload.cs b/runtime/csharp/Prompty.Core/Model/events/StatusEventPayload.cs index dee6783bd..a8ab2f50b 100644 --- a/runtime/csharp/Prompty.Core/Model/events/StatusEventPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/StatusEventPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -43,6 +46,7 @@ public StatusEventPayload() /// The loaded StatusEventPayload instance. public static StatusEventPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -55,7 +59,7 @@ public static StatusEventPayload Load(Dictionary data, LoadCont if (data.TryGetValue("message", out var messageValue) && messageValue is not null) { - instance.Message = messageValue?.ToString()!; + instance.Message = messageValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/StreamChunk.cs b/runtime/csharp/Prompty.Core/Model/events/StreamChunk.cs index 579741cd0..575fd51f0 100644 --- a/runtime/csharp/Prompty.Core/Model/events/StreamChunk.cs +++ b/runtime/csharp/Prompty.Core/Model/events/StreamChunk.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -45,6 +48,7 @@ protected StreamChunk() /// The loaded StreamChunk instance. public static StreamChunk Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -57,7 +61,7 @@ public static StreamChunk Load(Dictionary data, LoadContext? co if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (context is not null) @@ -75,7 +79,7 @@ private static StreamChunk LoadKind(Dictionary data, LoadContex { if (data.TryGetValue("kind", out var discriminatorValue) && discriminatorValue is not null) { - var discriminator = discriminatorValue.ToString()?.ToLowerInvariant(); + var discriminator = discriminatorValue.ToString(); return discriminator switch { "text" => TextChunk.Load(data, context), @@ -83,7 +87,7 @@ private static StreamChunk LoadKind(Dictionary data, LoadContex "tool" => ToolChunk.Load(data, context), "usage" => UsageChunk.Load(data, context), "error" => ErrorChunk.Load(data, context), - _ => throw new ArgumentException($"Unknown StreamChunk discriminator value: {discriminator}"), + _ => throw new ArgumentException($"Unknown StreamChunk discriminator field 'kind' value: {discriminator}"), }; } diff --git a/runtime/csharp/Prompty.Core/Model/events/TextChunk.cs b/runtime/csharp/Prompty.Core/Model/events/TextChunk.cs index 4f233e92a..53e848281 100644 --- a/runtime/csharp/Prompty.Core/Model/events/TextChunk.cs +++ b/runtime/csharp/Prompty.Core/Model/events/TextChunk.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,6 +51,7 @@ public TextChunk() /// The loaded TextChunk instance. public new static TextChunk Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -60,12 +64,12 @@ public TextChunk() if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("value", out var valueValue) && valueValue is not null) { - instance.Value = valueValue?.ToString()!; + instance.Value = valueValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/ThinkingChunk.cs b/runtime/csharp/Prompty.Core/Model/events/ThinkingChunk.cs index a9ea48dd6..36293e598 100644 --- a/runtime/csharp/Prompty.Core/Model/events/ThinkingChunk.cs +++ b/runtime/csharp/Prompty.Core/Model/events/ThinkingChunk.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,6 +51,7 @@ public ThinkingChunk() /// The loaded ThinkingChunk instance. public new static ThinkingChunk Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -60,12 +64,12 @@ public ThinkingChunk() if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("value", out var valueValue) && valueValue is not null) { - instance.Value = valueValue?.ToString()!; + instance.Value = valueValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/ThinkingEventPayload.cs b/runtime/csharp/Prompty.Core/Model/events/ThinkingEventPayload.cs index 67c710388..4ad547f7d 100644 --- a/runtime/csharp/Prompty.Core/Model/events/ThinkingEventPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/ThinkingEventPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -43,6 +46,7 @@ public ThinkingEventPayload() /// The loaded ThinkingEventPayload instance. public static ThinkingEventPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -55,7 +59,7 @@ public static ThinkingEventPayload Load(Dictionary data, LoadCo if (data.TryGetValue("token", out var tokenValue) && tokenValue is not null) { - instance.Token = tokenValue?.ToString()!; + instance.Token = tokenValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/TokenEventPayload.cs b/runtime/csharp/Prompty.Core/Model/events/TokenEventPayload.cs index b8f1430c2..592a7464d 100644 --- a/runtime/csharp/Prompty.Core/Model/events/TokenEventPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/TokenEventPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -43,6 +46,7 @@ public TokenEventPayload() /// The loaded TokenEventPayload instance. public static TokenEventPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -55,7 +59,7 @@ public static TokenEventPayload Load(Dictionary data, LoadConte if (data.TryGetValue("token", out var tokenValue) && tokenValue is not null) { - instance.Token = tokenValue?.ToString()!; + instance.Token = tokenValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/ToolCallCompletePayload.cs b/runtime/csharp/Prompty.Core/Model/events/ToolCallCompletePayload.cs index 3dad5288a..cb9cc0533 100644 --- a/runtime/csharp/Prompty.Core/Model/events/ToolCallCompletePayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/ToolCallCompletePayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -68,6 +71,7 @@ public ToolCallCompletePayload() /// The loaded ToolCallCompletePayload instance. public static ToolCallCompletePayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -80,12 +84,12 @@ public static ToolCallCompletePayload Load(Dictionary data, Loa if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("success", out var successValue) && successValue is not null) @@ -95,7 +99,7 @@ public static ToolCallCompletePayload Load(Dictionary data, Loa if (data.TryGetValue("result", out var resultValue) && resultValue is not null) { - instance.Result = ToolResult.Load(resultValue.GetDictionary(ToolResult.ShorthandProperty), context); + instance.Result = ToolResult.Load(resultValue.GetDictionary(ToolResult.ShorthandProperty), context!.At("result")); } if (data.TryGetValue("durationMs", out var durationMsValue) && durationMsValue is not null) @@ -105,7 +109,7 @@ public static ToolCallCompletePayload Load(Dictionary data, Loa if (data.TryGetValue("errorKind", out var errorKindValue) && errorKindValue is not null) { - instance.ErrorKind = errorKindValue?.ToString()!; + instance.ErrorKind = errorKindValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/ToolCallStartPayload.cs b/runtime/csharp/Prompty.Core/Model/events/ToolCallStartPayload.cs index 6ec361ce5..ea2fb8287 100644 --- a/runtime/csharp/Prompty.Core/Model/events/ToolCallStartPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/ToolCallStartPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public ToolCallStartPayload() /// The loaded ToolCallStartPayload instance. public static ToolCallStartPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,17 +69,17 @@ public static ToolCallStartPayload Load(Dictionary data, LoadCo if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("arguments", out var argumentsValue) && argumentsValue is not null) { - instance.Arguments = argumentsValue?.ToString()!; + instance.Arguments = argumentsValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/ToolChunk.cs b/runtime/csharp/Prompty.Core/Model/events/ToolChunk.cs index 34e3f2039..dfe6861a1 100644 --- a/runtime/csharp/Prompty.Core/Model/events/ToolChunk.cs +++ b/runtime/csharp/Prompty.Core/Model/events/ToolChunk.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,24 +51,30 @@ public ToolChunk() /// The loaded ToolChunk instance. public new static ToolChunk Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("toolCall", out var requiredToolCallValue) || requiredToolCallValue is null)) + { + throw new ArgumentException($"{context!.At("toolCall").Path}: missing required field"); + } + // Create new instance var instance = new ToolChunk(); if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("toolCall", out var toolCallValue) && toolCallValue is not null) { - instance.ToolCall = ToolCall.Load(toolCallValue.GetDictionary(ToolCall.ShorthandProperty), context); + instance.ToolCall = ToolCall.Load(toolCallValue.GetDictionary(ToolCall.ShorthandProperty), context!.At("toolCall")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/ToolExecutionCompletePayload.cs b/runtime/csharp/Prompty.Core/Model/events/ToolExecutionCompletePayload.cs index 74a7a9fd5..3cbd08080 100644 --- a/runtime/csharp/Prompty.Core/Model/events/ToolExecutionCompletePayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/ToolExecutionCompletePayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -69,7 +72,7 @@ public ToolExecutionCompletePayload() /// /// Host-specific telemetry for the execution /// - public IDictionary? Telemetry { get; set; } + public IDictionary? Telemetry { get; set; } /// /// Redaction state for sensitive result fields @@ -88,6 +91,7 @@ public ToolExecutionCompletePayload() /// The loaded ToolExecutionCompletePayload instance. public static ToolExecutionCompletePayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -100,17 +104,17 @@ public static ToolExecutionCompletePayload Load(Dictionary data if (data.TryGetValue("requestId", out var requestIdValue) && requestIdValue is not null) { - instance.RequestId = requestIdValue?.ToString()!; + instance.RequestId = requestIdValue.ToString()!; } if (data.TryGetValue("toolCallId", out var toolCallIdValue) && toolCallIdValue is not null) { - instance.ToolCallId = toolCallIdValue?.ToString()!; + instance.ToolCallId = toolCallIdValue.ToString()!; } if (data.TryGetValue("toolName", out var toolNameValue) && toolNameValue is not null) { - instance.ToolName = toolNameValue?.ToString()!; + instance.ToolName = toolNameValue.ToString()!; } if (data.TryGetValue("success", out var successValue) && successValue is not null) @@ -135,7 +139,7 @@ public static ToolExecutionCompletePayload Load(Dictionary data if (data.TryGetValue("errorKind", out var errorKindValue) && errorKindValue is not null) { - instance.ErrorKind = errorKindValue?.ToString()!; + instance.ErrorKind = errorKindValue.ToString()!; } if (data.TryGetValue("telemetry", out var telemetryValue) && telemetryValue is not null) @@ -145,7 +149,7 @@ public static ToolExecutionCompletePayload Load(Dictionary data if (data.TryGetValue("redaction", out var redactionValue) && redactionValue is not null) { - instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context); + instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context!.At("redaction")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/ToolExecutionStartPayload.cs b/runtime/csharp/Prompty.Core/Model/events/ToolExecutionStartPayload.cs index a7aade304..0d88da229 100644 --- a/runtime/csharp/Prompty.Core/Model/events/ToolExecutionStartPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/ToolExecutionStartPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,7 +51,7 @@ public ToolExecutionStartPayload() /// /// Tool arguments after host-side sanitization /// - public IDictionary? Arguments { get; set; } + public IDictionary? Arguments { get; set; } /// /// Working directory or execution scope for the tool @@ -72,6 +75,7 @@ public ToolExecutionStartPayload() /// The loaded ToolExecutionStartPayload instance. public static ToolExecutionStartPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -84,17 +88,17 @@ public static ToolExecutionStartPayload Load(Dictionary data, L if (data.TryGetValue("requestId", out var requestIdValue) && requestIdValue is not null) { - instance.RequestId = requestIdValue?.ToString()!; + instance.RequestId = requestIdValue.ToString()!; } if (data.TryGetValue("toolCallId", out var toolCallIdValue) && toolCallIdValue is not null) { - instance.ToolCallId = toolCallIdValue?.ToString()!; + instance.ToolCallId = toolCallIdValue.ToString()!; } if (data.TryGetValue("toolName", out var toolNameValue) && toolNameValue is not null) { - instance.ToolName = toolNameValue?.ToString()!; + instance.ToolName = toolNameValue.ToString()!; } if (data.TryGetValue("arguments", out var argumentsValue) && argumentsValue is not null) @@ -104,12 +108,12 @@ public static ToolExecutionStartPayload Load(Dictionary data, L if (data.TryGetValue("workingDirectory", out var workingDirectoryValue) && workingDirectoryValue is not null) { - instance.WorkingDirectory = workingDirectoryValue?.ToString()!; + instance.WorkingDirectory = workingDirectoryValue.ToString()!; } if (data.TryGetValue("redaction", out var redactionValue) && redactionValue is not null) { - instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context); + instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context!.At("redaction")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/ToolResultPayload.cs b/runtime/csharp/Prompty.Core/Model/events/ToolResultPayload.cs index b9c2bce2c..5181f25ad 100644 --- a/runtime/csharp/Prompty.Core/Model/events/ToolResultPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/ToolResultPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,24 +51,30 @@ public ToolResultPayload() /// The loaded ToolResultPayload instance. public static ToolResultPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("result", out var requiredResultValue) || requiredResultValue is null)) + { + throw new ArgumentException($"{context!.At("result").Path}: missing required field"); + } + // Create new instance var instance = new ToolResultPayload(); if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("result", out var resultValue) && resultValue is not null) { - instance.Result = ToolResult.Load(resultValue.GetDictionary(ToolResult.ShorthandProperty), context); + instance.Result = ToolResult.Load(resultValue.GetDictionary(ToolResult.ShorthandProperty), context!.At("result")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/TrajectoryEvent.cs b/runtime/csharp/Prompty.Core/Model/events/TrajectoryEvent.cs index b860c3fdc..0d45f5e8d 100644 --- a/runtime/csharp/Prompty.Core/Model/events/TrajectoryEvent.cs +++ b/runtime/csharp/Prompty.Core/Model/events/TrajectoryEvent.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -59,7 +62,7 @@ public TrajectoryEvent() /// /// Sanitized event data /// - public IDictionary? Data { get; set; } + public IDictionary? Data { get; set; } /// /// ISO 8601 UTC timestamp when the trajectory event was recorded @@ -83,6 +86,7 @@ public TrajectoryEvent() /// The loaded TrajectoryEvent instance. public static TrajectoryEvent Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -95,22 +99,22 @@ public static TrajectoryEvent Load(Dictionary data, LoadContext if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("toolCallId", out var toolCallIdValue) && toolCallIdValue is not null) { - instance.ToolCallId = toolCallIdValue?.ToString()!; + instance.ToolCallId = toolCallIdValue.ToString()!; } if (data.TryGetValue("turnIndex", out var turnIndexValue) && turnIndexValue is not null) @@ -120,7 +124,7 @@ public static TrajectoryEvent Load(Dictionary data, LoadContext if (data.TryGetValue("eventType", out var eventTypeValue) && eventTypeValue is not null) { - instance.EventType = eventTypeValue?.ToString()!; + instance.EventType = eventTypeValue.ToString()!; } if (data.TryGetValue("data", out var dataValue) && dataValue is not null) @@ -130,12 +134,12 @@ public static TrajectoryEvent Load(Dictionary data, LoadContext if (data.TryGetValue("createdAt", out var createdAtValue) && createdAtValue is not null) { - instance.CreatedAt = createdAtValue?.ToString()!; + instance.CreatedAt = createdAtValue.ToString()!; } if (data.TryGetValue("redaction", out var redactionValue) && redactionValue is not null) { - instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context); + instance.Redaction = RedactionMetadata.Load(redactionValue.GetDictionary(RedactionMetadata.ShorthandProperty), context!.At("redaction")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/TurnEndPayload.cs b/runtime/csharp/Prompty.Core/Model/events/TurnEndPayload.cs index 093de2dda..a0a48e5ac 100644 --- a/runtime/csharp/Prompty.Core/Model/events/TurnEndPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/TurnEndPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -58,6 +61,7 @@ public TurnEndPayload() /// The loaded TurnEndPayload instance. public static TurnEndPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -75,7 +79,7 @@ public static TurnEndPayload Load(Dictionary data, LoadContext? if (data.TryGetValue("status", out var statusValue) && statusValue is not null) { - instance.Status = TurnStatusParser.Parse(statusValue?.ToString()!); + instance.Status = TurnStatusParser.Parse(statusValue.ToString()!); } if (data.TryGetValue("response", out var responseValue) && responseValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/TurnEvent.cs b/runtime/csharp/Prompty.Core/Model/events/TurnEvent.cs index 4519cfd67..c8fd022ae 100644 --- a/runtime/csharp/Prompty.Core/Model/events/TurnEvent.cs +++ b/runtime/csharp/Prompty.Core/Model/events/TurnEvent.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -66,9 +69,9 @@ public TurnEvent() public string? SpanId { get; set; } /// - /// Event-specific payload. Use the typed payload model matching 'type'. + /// Event-specific payload. Values may be explicit null. Use the typed payload model matching 'type'. /// - public IDictionary Payload { get; set; } = new Dictionary(); + public IDictionary Payload { get; set; } = new Dictionary(); @@ -82,6 +85,7 @@ public TurnEvent() /// The loaded TurnEvent instance. public static TurnEvent Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -94,22 +98,22 @@ public static TurnEvent Load(Dictionary data, LoadContext? cont if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("type", out var typeValue) && typeValue is not null) { - instance.Type = TurnEventTypeParser.Parse(typeValue?.ToString()!); + instance.Type = TurnEventTypeParser.Parse(typeValue.ToString()!); } if (data.TryGetValue("timestamp", out var timestampValue) && timestampValue is not null) { - instance.Timestamp = timestampValue?.ToString()!; + instance.Timestamp = timestampValue.ToString()!; } if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("iteration", out var iterationValue) && iterationValue is not null) @@ -119,12 +123,12 @@ public static TurnEvent Load(Dictionary data, LoadContext? cont if (data.TryGetValue("parentId", out var parentIdValue) && parentIdValue is not null) { - instance.ParentId = parentIdValue?.ToString()!; + instance.ParentId = parentIdValue.ToString()!; } if (data.TryGetValue("spanId", out var spanIdValue) && spanIdValue is not null) { - instance.SpanId = spanIdValue?.ToString()!; + instance.SpanId = spanIdValue.ToString()!; } if (data.TryGetValue("payload", out var payloadValue) && payloadValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/TurnEventType.cs b/runtime/csharp/Prompty.Core/Model/events/TurnEventType.cs index b4bf7b887..cb6baa8fa 100644 --- a/runtime/csharp/Prompty.Core/Model/events/TurnEventType.cs +++ b/runtime/csharp/Prompty.Core/Model/events/TurnEventType.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/events/TurnStartPayload.cs b/runtime/csharp/Prompty.Core/Model/events/TurnStartPayload.cs index 53390b3b2..e82b0798d 100644 --- a/runtime/csharp/Prompty.Core/Model/events/TurnStartPayload.cs +++ b/runtime/csharp/Prompty.Core/Model/events/TurnStartPayload.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -34,7 +37,7 @@ public TurnStartPayload() /// /// Input values supplied to the turn after host-side sanitization /// - public IDictionary? Inputs { get; set; } + public IDictionary? Inputs { get; set; } /// /// Configured maximum tool-call iterations @@ -53,6 +56,7 @@ public TurnStartPayload() /// The loaded TurnStartPayload instance. public static TurnStartPayload Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,7 +69,7 @@ public static TurnStartPayload Load(Dictionary data, LoadContex if (data.TryGetValue("agent", out var agentValue) && agentValue is not null) { - instance.Agent = agentValue?.ToString()!; + instance.Agent = agentValue.ToString()!; } if (data.TryGetValue("inputs", out var inputsValue) && inputsValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/TurnStatus.cs b/runtime/csharp/Prompty.Core/Model/events/TurnStatus.cs index abe6b9e26..f16028140 100644 --- a/runtime/csharp/Prompty.Core/Model/events/TurnStatus.cs +++ b/runtime/csharp/Prompty.Core/Model/events/TurnStatus.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/events/TurnSummary.cs b/runtime/csharp/Prompty.Core/Model/events/TurnSummary.cs index 0b8732caf..f9c25be46 100644 --- a/runtime/csharp/Prompty.Core/Model/events/TurnSummary.cs +++ b/runtime/csharp/Prompty.Core/Model/events/TurnSummary.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -78,6 +81,7 @@ public TurnSummary() /// The loaded TurnSummary instance. public static TurnSummary Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -90,12 +94,12 @@ public static TurnSummary Load(Dictionary data, LoadContext? co if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("status", out var statusValue) && statusValue is not null) { - instance.Status = statusValue?.ToString()!; + instance.Status = statusValue.ToString()!; } if (data.TryGetValue("iterations", out var iterationsValue) && iterationsValue is not null) @@ -120,7 +124,7 @@ public static TurnSummary Load(Dictionary data, LoadContext? co if (data.TryGetValue("usage", out var usageValue) && usageValue is not null) { - instance.Usage = TokenUsage.Load(usageValue.GetDictionary(TokenUsage.ShorthandProperty), context); + instance.Usage = TokenUsage.Load(usageValue.GetDictionary(TokenUsage.ShorthandProperty), context!.At("usage")); } if (data.TryGetValue("durationMs", out var durationMsValue) && durationMsValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/events/TurnTrace.cs b/runtime/csharp/Prompty.Core/Model/events/TurnTrace.cs index 4834e03e1..550783dde 100644 --- a/runtime/csharp/Prompty.Core/Model/events/TurnTrace.cs +++ b/runtime/csharp/Prompty.Core/Model/events/TurnTrace.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -63,6 +66,7 @@ public TurnTrace() /// The loaded TurnTrace instance. public static TurnTrace Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -75,27 +79,27 @@ public static TurnTrace Load(Dictionary data, LoadContext? cont if (data.TryGetValue("version", out var versionValue) && versionValue is not null) { - instance.Version = versionValue?.ToString()!; + instance.Version = versionValue.ToString()!; } if (data.TryGetValue("runtime", out var runtimeValue) && runtimeValue is not null) { - instance.Runtime = runtimeValue?.ToString()!; + instance.Runtime = runtimeValue.ToString()!; } if (data.TryGetValue("promptyVersion", out var promptyVersionValue) && promptyVersionValue is not null) { - instance.PromptyVersion = promptyVersionValue?.ToString()!; + instance.PromptyVersion = promptyVersionValue.ToString()!; } if (data.TryGetValue("events", out var eventsValue) && eventsValue is not null) { - instance.Events = LoadEvents(eventsValue, context); + instance.Events = LoadEvents(eventsValue, context!.At("events")); } if (data.TryGetValue("summary", out var summaryValue) && summaryValue is not null) { - instance.Summary = TurnSummary.Load(summaryValue.GetDictionary(TurnSummary.ShorthandProperty), context); + instance.Summary = TurnSummary.Load(summaryValue.GetDictionary(TurnSummary.ShorthandProperty), context!.At("summary")); } if (context is not null) @@ -113,46 +117,49 @@ public static IList LoadEvents(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'events' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "events" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'events' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(TurnEvent.Load(itemDict, context)); + result.Add(TurnEvent.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["id"] = kvp.Value }; - result.Add(TurnEvent.Load(newDict, context)); + result.Add(TurnEvent.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(TurnEvent.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(TurnEvent.Load(itemDict, context)); + result.Add(TurnEvent.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/events/UsageChunk.cs b/runtime/csharp/Prompty.Core/Model/events/UsageChunk.cs index cc0979b59..f8bb0d230 100644 --- a/runtime/csharp/Prompty.Core/Model/events/UsageChunk.cs +++ b/runtime/csharp/Prompty.Core/Model/events/UsageChunk.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,24 +51,30 @@ public UsageChunk() /// The loaded UsageChunk instance. public new static UsageChunk Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("usage", out var requiredUsageValue) || requiredUsageValue is null)) + { + throw new ArgumentException($"{context!.At("usage").Path}: missing required field"); + } + // Create new instance var instance = new UsageChunk(); if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("usage", out var usageValue) && usageValue is not null) { - instance.Usage = InvocationUsage.Load(usageValue.GetDictionary(InvocationUsage.ShorthandProperty), context); + instance.Usage = InvocationUsage.Load(usageValue.GetDictionary(InvocationUsage.ShorthandProperty), context!.At("usage")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/memory/MemoryCategory.cs b/runtime/csharp/Prompty.Core/Model/memory/MemoryCategory.cs index b9b2fc5eb..fc890062d 100644 --- a/runtime/csharp/Prompty.Core/Model/memory/MemoryCategory.cs +++ b/runtime/csharp/Prompty.Core/Model/memory/MemoryCategory.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/memory/MemoryEntry.cs b/runtime/csharp/Prompty.Core/Model/memory/MemoryEntry.cs index 59a2eea64..7f81aef07 100644 --- a/runtime/csharp/Prompty.Core/Model/memory/MemoryEntry.cs +++ b/runtime/csharp/Prompty.Core/Model/memory/MemoryEntry.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -74,6 +77,7 @@ public MemoryEntry() /// The loaded MemoryEntry instance. public static MemoryEntry Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -86,17 +90,17 @@ public static MemoryEntry Load(Dictionary data, LoadContext? co if (data.TryGetValue("content", out var contentValue) && contentValue is not null) { - instance.Content = contentValue?.ToString()!; + instance.Content = contentValue.ToString()!; } if (data.TryGetValue("category", out var categoryValue) && categoryValue is not null) { - instance.Category = MemoryCategoryParser.Parse(categoryValue?.ToString()!); + instance.Category = MemoryCategoryParser.Parse(categoryValue.ToString()!); } if (data.TryGetValue("createdAt", out var createdAtValue) && createdAtValue is not null) { - instance.CreatedAt = createdAtValue?.ToString()!; + instance.CreatedAt = createdAtValue.ToString()!; } if (data.TryGetValue("tags", out var tagsValue) && tagsValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/memory/MemoryStore.cs b/runtime/csharp/Prompty.Core/Model/memory/MemoryStore.cs index e120c7248..de3f03765 100644 --- a/runtime/csharp/Prompty.Core/Model/memory/MemoryStore.cs +++ b/runtime/csharp/Prompty.Core/Model/memory/MemoryStore.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -51,6 +54,7 @@ public MemoryStore() /// The loaded MemoryStore instance. public static MemoryStore Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -63,7 +67,7 @@ public static MemoryStore Load(Dictionary data, LoadContext? co if (data.TryGetValue("entries", out var entriesValue) && entriesValue is not null) { - instance.Entries = LoadEntries(entriesValue, context); + instance.Entries = LoadEntries(entriesValue, context!.At("entries")); } if (context is not null) @@ -81,46 +85,49 @@ public static IList LoadEntries(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'entries' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "entries" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'entries' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(MemoryEntry.Load(itemDict, context)); + result.Add(MemoryEntry.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["content"] = kvp.Value }; - result.Add(MemoryEntry.Load(newDict, context)); + result.Add(MemoryEntry.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(MemoryEntry.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(MemoryEntry.Load(itemDict, context)); + result.Add(MemoryEntry.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/model/AiResourceInfo.cs b/runtime/csharp/Prompty.Core/Model/model/AiResourceInfo.cs index 226273b47..408a89087 100644 --- a/runtime/csharp/Prompty.Core/Model/model/AiResourceInfo.cs +++ b/runtime/csharp/Prompty.Core/Model/model/AiResourceInfo.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -68,6 +71,7 @@ public AiResourceInfo() /// The loaded AiResourceInfo instance. public static AiResourceInfo Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -80,32 +84,32 @@ public static AiResourceInfo Load(Dictionary data, LoadContext? if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("endpoint", out var endpointValue) && endpointValue is not null) { - instance.Endpoint = endpointValue?.ToString()!; + instance.Endpoint = endpointValue.ToString()!; } if (data.TryGetValue("location", out var locationValue) && locationValue is not null) { - instance.Location = locationValue?.ToString()!; + instance.Location = locationValue.ToString()!; } if (data.TryGetValue("resourceGroup", out var resourceGroupValue) && resourceGroupValue is not null) { - instance.ResourceGroup = resourceGroupValue?.ToString()!; + instance.ResourceGroup = resourceGroupValue.ToString()!; } if (data.TryGetValue("serviceUrl", out var serviceUrlValue) && serviceUrlValue is not null) { - instance.ServiceUrl = serviceUrlValue?.ToString()!; + instance.ServiceUrl = serviceUrlValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/model/InvocationUsage.cs b/runtime/csharp/Prompty.Core/Model/model/InvocationUsage.cs index 8eabcde3b..c646d3ec1 100644 --- a/runtime/csharp/Prompty.Core/Model/model/InvocationUsage.cs +++ b/runtime/csharp/Prompty.Core/Model/model/InvocationUsage.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -59,6 +62,7 @@ public InvocationUsage() /// The loaded InvocationUsage instance. public static InvocationUsage Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); diff --git a/runtime/csharp/Prompty.Core/Model/model/Model.cs b/runtime/csharp/Prompty.Core/Model/model/Model.cs index 8b1dd4cf0..605f46f5b 100644 --- a/runtime/csharp/Prompty.Core/Model/model/Model.cs +++ b/runtime/csharp/Prompty.Core/Model/model/Model.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -67,6 +70,7 @@ public Model() /// The loaded Model instance. public static Model Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -80,27 +84,27 @@ public static Model Load(Dictionary data, LoadContext? context if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("provider", out var providerValue) && providerValue is not null) { - instance.Provider = providerValue?.ToString()!; + instance.Provider = providerValue.ToString()!; } if (data.TryGetValue("apiType", out var apiTypeValue) && apiTypeValue is not null) { - instance.ApiType = apiTypeValue?.ToString()!; + instance.ApiType = apiTypeValue.ToString()!; } if (data.TryGetValue("connection", out var connectionValue) && connectionValue is not null) { - instance.Connection = Connection.Load(connectionValue.GetDictionary(Connection.ShorthandProperty), context); + instance.Connection = Connection.Load(connectionValue.GetDictionary(Connection.ShorthandProperty), context!.At("connection")); } if (data.TryGetValue("options", out var optionsValue) && optionsValue is not null) { - instance.Options = ModelOptions.Load(optionsValue.GetDictionary(ModelOptions.ShorthandProperty), context); + instance.Options = ModelOptions.Load(optionsValue.GetDictionary(ModelOptions.ShorthandProperty), context!.At("options")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/model/ModelInfo.cs b/runtime/csharp/Prompty.Core/Model/model/ModelInfo.cs index 28c7ecdd6..0a5cba209 100644 --- a/runtime/csharp/Prompty.Core/Model/model/ModelInfo.cs +++ b/runtime/csharp/Prompty.Core/Model/model/ModelInfo.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -65,9 +68,9 @@ public ModelInfo() public IList? OutputModalities { get; set; } /// - /// Additional provider-specific properties + /// Additional provider-specific properties. Values may be explicit null. /// - public IDictionary? AdditionalProperties { get; set; } + public IDictionary? AdditionalProperties { get; set; } @@ -81,6 +84,7 @@ public ModelInfo() /// The loaded ModelInfo instance. public static ModelInfo Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -93,17 +97,17 @@ public static ModelInfo Load(Dictionary data, LoadContext? cont if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("displayName", out var displayNameValue) && displayNameValue is not null) { - instance.DisplayName = displayNameValue?.ToString()!; + instance.DisplayName = displayNameValue.ToString()!; } if (data.TryGetValue("ownedBy", out var ownedByValue) && ownedByValue is not null) { - instance.OwnedBy = ownedByValue?.ToString()!; + instance.OwnedBy = ownedByValue.ToString()!; } if (data.TryGetValue("contextWindow", out var contextWindowValue) && contextWindowValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/model/ModelLister.cs b/runtime/csharp/Prompty.Core/Model/model/ModelLister.cs index 2a39e9d99..447f6707b 100644 --- a/runtime/csharp/Prompty.Core/Model/model/ModelLister.cs +++ b/runtime/csharp/Prompty.Core/Model/model/ModelLister.cs @@ -1,5 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + +using System.Threading; +using System.Threading.Tasks; #pragma warning disable IDE0130 namespace Prompty.Core; diff --git a/runtime/csharp/Prompty.Core/Model/model/ModelOptions.cs b/runtime/csharp/Prompty.Core/Model/model/ModelOptions.cs index 8842fb927..8ad5b3bb8 100644 --- a/runtime/csharp/Prompty.Core/Model/model/ModelOptions.cs +++ b/runtime/csharp/Prompty.Core/Model/model/ModelOptions.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -74,7 +77,7 @@ public ModelOptions() /// /// Additional custom properties for model options /// - public IDictionary? AdditionalProperties { get; set; } + public IDictionary? AdditionalProperties { get; set; } @@ -88,6 +91,7 @@ public ModelOptions() /// The loaded ModelOptions instance. public static ModelOptions Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); diff --git a/runtime/csharp/Prompty.Core/Model/model/ProjectInfo.cs b/runtime/csharp/Prompty.Core/Model/model/ProjectInfo.cs index 7c19da251..6a2f2d65b 100644 --- a/runtime/csharp/Prompty.Core/Model/model/ProjectInfo.cs +++ b/runtime/csharp/Prompty.Core/Model/model/ProjectInfo.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public ProjectInfo() /// The loaded ProjectInfo instance. public static ProjectInfo Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,17 +69,17 @@ public static ProjectInfo Load(Dictionary data, LoadContext? co if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("displayName", out var displayNameValue) && displayNameValue is not null) { - instance.DisplayName = displayNameValue?.ToString()!; + instance.DisplayName = displayNameValue.ToString()!; } if (data.TryGetValue("endpoint", out var endpointValue) && endpointValue is not null) { - instance.Endpoint = endpointValue?.ToString()!; + instance.Endpoint = endpointValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/model/SubscriptionInfo.cs b/runtime/csharp/Prompty.Core/Model/model/SubscriptionInfo.cs index ae8e14b54..b3885de1b 100644 --- a/runtime/csharp/Prompty.Core/Model/model/SubscriptionInfo.cs +++ b/runtime/csharp/Prompty.Core/Model/model/SubscriptionInfo.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public SubscriptionInfo() /// The loaded SubscriptionInfo instance. public static SubscriptionInfo Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,17 +69,17 @@ public static SubscriptionInfo Load(Dictionary data, LoadContex if (data.TryGetValue("subscriptionId", out var subscriptionIdValue) && subscriptionIdValue is not null) { - instance.SubscriptionId = subscriptionIdValue?.ToString()!; + instance.SubscriptionId = subscriptionIdValue.ToString()!; } if (data.TryGetValue("displayName", out var displayNameValue) && displayNameValue is not null) { - instance.DisplayName = displayNameValue?.ToString()!; + instance.DisplayName = displayNameValue.ToString()!; } if (data.TryGetValue("state", out var stateValue) && stateValue is not null) { - instance.State = stateValue?.ToString()!; + instance.State = stateValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/model/TokenUsage.cs b/runtime/csharp/Prompty.Core/Model/model/TokenUsage.cs index aec8a5330..5ff446db5 100644 --- a/runtime/csharp/Prompty.Core/Model/model/TokenUsage.cs +++ b/runtime/csharp/Prompty.Core/Model/model/TokenUsage.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -57,6 +60,7 @@ public TokenUsage() /// The loaded TokenUsage instance. public static TokenUsage Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/CheckpointStore.cs b/runtime/csharp/Prompty.Core/Model/pipeline/CheckpointStore.cs index 89a41b65d..5bb2345ea 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/CheckpointStore.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/CheckpointStore.cs @@ -1,5 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + +using System.Threading; +using System.Threading.Tasks; #pragma warning disable IDE0130 namespace Prompty.Core; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/CompactionConfig.cs b/runtime/csharp/Prompty.Core/Model/pipeline/CompactionConfig.cs index 438375d92..4e7697643 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/CompactionConfig.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/CompactionConfig.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -43,7 +46,7 @@ public CompactionConfig() /// /// Additional strategy-specific options /// - public IDictionary? Options { get; set; } + public IDictionary? Options { get; set; } @@ -57,6 +60,7 @@ public CompactionConfig() /// The loaded CompactionConfig instance. public static CompactionConfig Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -69,7 +73,7 @@ public static CompactionConfig Load(Dictionary data, LoadContex if (data.TryGetValue("strategy", out var strategyValue) && strategyValue is not null) { - instance.Strategy = strategyValue?.ToString()!; + instance.Strategy = strategyValue.ToString()!; } if (data.TryGetValue("budget", out var budgetValue) && budgetValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ContextCandidate.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ContextCandidate.cs index 3617a2c08..e0d3ba166 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ContextCandidate.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ContextCandidate.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -44,7 +47,7 @@ public ContextCandidate() /// /// Opaque host-specific candidate metadata /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } @@ -58,6 +61,7 @@ public ContextCandidate() /// The loaded ContextCandidate instance. public static ContextCandidate Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -70,17 +74,17 @@ public static ContextCandidate Load(Dictionary data, LoadContex if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("source", out var sourceValue) && sourceValue is not null) { - instance.Source = sourceValue?.ToString()!; + instance.Source = sourceValue.ToString()!; } if (data.TryGetValue("messages", out var messagesValue) && messagesValue is not null) { - instance.Messages = LoadMessages(messagesValue, context); + instance.Messages = LoadMessages(messagesValue, context!.At("messages")); } if (data.TryGetValue("metadata", out var metadataValue) && metadataValue is not null) @@ -103,46 +107,49 @@ public static IList LoadMessages(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'messages' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "messages" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'messages' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["role"] = kvp.Value }; - result.Add(Message.Load(newDict, context)); + result.Add(Message.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Message.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ContextRequest.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ContextRequest.cs index ce072d415..4eea7b7e7 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ContextRequest.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ContextRequest.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -78,29 +81,35 @@ public ContextRequest() /// The loaded ContextRequest instance. public static ContextRequest Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("contextState", out var requiredContextStateValue) || requiredContextStateValue is null)) + { + throw new ArgumentException($"{context!.At("contextState").Path}: missing required field"); + } + // Create new instance var instance = new ContextRequest(); if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("invocationId", out var invocationIdValue) && invocationIdValue is not null) { - instance.InvocationId = invocationIdValue?.ToString()!; + instance.InvocationId = invocationIdValue.ToString()!; } if (data.TryGetValue("iteration", out var iterationValue) && iterationValue is not null) @@ -110,7 +119,7 @@ public static ContextRequest Load(Dictionary data, LoadContext? if (data.TryGetValue("messages", out var messagesValue) && messagesValue is not null) { - instance.Messages = LoadMessages(messagesValue, context); + instance.Messages = LoadMessages(messagesValue, context!.At("messages")); } if (data.TryGetValue("stablePrefixMessages", out var stablePrefixMessagesValue) && stablePrefixMessagesValue is not null) @@ -120,7 +129,7 @@ public static ContextRequest Load(Dictionary data, LoadContext? if (data.TryGetValue("contextState", out var contextStateValue) && contextStateValue is not null) { - instance.ContextState = InvocationContextState.Load(contextStateValue.GetDictionary(InvocationContextState.ShorthandProperty), context); + instance.ContextState = InvocationContextState.Load(contextStateValue.GetDictionary(InvocationContextState.ShorthandProperty), context!.At("contextState")); } if (data.TryGetValue("inputs", out var inputsValue) && inputsValue is not null) @@ -143,46 +152,49 @@ public static IList LoadMessages(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'messages' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "messages" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'messages' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["role"] = kvp.Value }; - result.Add(Message.Load(newDict, context)); + result.Add(Message.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Message.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/DelegatedStateReference.cs b/runtime/csharp/Prompty.Core/Model/pipeline/DelegatedStateReference.cs index 17a413889..6beda09d0 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/DelegatedStateReference.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/DelegatedStateReference.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -44,7 +47,7 @@ public DelegatedStateReference() /// /// Opaque provider-specific state reference metadata /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } @@ -58,6 +61,7 @@ public DelegatedStateReference() /// The loaded DelegatedStateReference instance. public static DelegatedStateReference Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -70,17 +74,17 @@ public static DelegatedStateReference Load(Dictionary data, Loa if (data.TryGetValue("provider", out var providerValue) && providerValue is not null) { - instance.Provider = providerValue?.ToString()!; + instance.Provider = providerValue.ToString()!; } if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("metadata", out var metadataValue) && metadataValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/EngineCheckpoint.cs b/runtime/csharp/Prompty.Core/Model/pipeline/EngineCheckpoint.cs index d1ca72089..49ae6bccb 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/EngineCheckpoint.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/EngineCheckpoint.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -95,12 +98,12 @@ public EngineCheckpoint() /// /// Tool requests awaiting execution or commitment /// - public IList? PendingToolRequests { get; set; } + public IList? PendingToolRequests { get; set; } = []; /// /// Tool results already executed in this round /// - public IList? CompletedToolResults { get; set; } + public IList? CompletedToolResults { get; set; } = []; /// /// Number of fully completed model iterations @@ -150,7 +153,7 @@ public EngineCheckpoint() /// /// Opaque host-specific checkpoint metadata /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } @@ -164,39 +167,45 @@ public EngineCheckpoint() /// The loaded EngineCheckpoint instance. public static EngineCheckpoint Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("contextState", out var requiredContextStateValue) || requiredContextStateValue is null)) + { + throw new ArgumentException($"{context!.At("contextState").Path}: missing required field"); + } + // Create new instance var instance = new EngineCheckpoint(); if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("runId", out var runIdValue) && runIdValue is not null) { - instance.RunId = runIdValue?.ToString()!; + instance.RunId = runIdValue.ToString()!; } if (data.TryGetValue("parentRunId", out var parentRunIdValue) && parentRunIdValue is not null) { - instance.ParentRunId = parentRunIdValue?.ToString()!; + instance.ParentRunId = parentRunIdValue.ToString()!; } if (data.TryGetValue("delegationDepth", out var delegationDepthValue) && delegationDepthValue is not null) @@ -216,7 +225,7 @@ public static EngineCheckpoint Load(Dictionary data, LoadContex if (data.TryGetValue("messages", out var messagesValue) && messagesValue is not null) { - instance.Messages = LoadMessages(messagesValue, context); + instance.Messages = LoadMessages(messagesValue, context!.At("messages")); } if (data.TryGetValue("stablePrefixMessages", out var stablePrefixMessagesValue) && stablePrefixMessagesValue is not null) @@ -231,17 +240,17 @@ public static EngineCheckpoint Load(Dictionary data, LoadContex if (data.TryGetValue("activeInvocationId", out var activeInvocationIdValue) && activeInvocationIdValue is not null) { - instance.ActiveInvocationId = activeInvocationIdValue?.ToString()!; + instance.ActiveInvocationId = activeInvocationIdValue.ToString()!; } if (data.TryGetValue("pendingToolRequests", out var pendingToolRequestsValue) && pendingToolRequestsValue is not null) { - instance.PendingToolRequests = LoadPendingToolRequests(pendingToolRequestsValue, context); + instance.PendingToolRequests = LoadPendingToolRequests(pendingToolRequestsValue, context!.At("pendingToolRequests")); } if (data.TryGetValue("completedToolResults", out var completedToolResultsValue) && completedToolResultsValue is not null) { - instance.CompletedToolResults = LoadCompletedToolResults(completedToolResultsValue, context); + instance.CompletedToolResults = LoadCompletedToolResults(completedToolResultsValue, context!.At("completedToolResults")); } if (data.TryGetValue("completedModelIterations", out var completedModelIterationsValue) && completedModelIterationsValue is not null) @@ -256,7 +265,7 @@ public static EngineCheckpoint Load(Dictionary data, LoadContex if (data.TryGetValue("modelReconciliation", out var modelReconciliationValue) && modelReconciliationValue is not null) { - instance.ModelReconciliation = ModelReconciliationState.Load(modelReconciliationValue.GetDictionary(ModelReconciliationState.ShorthandProperty), context); + instance.ModelReconciliation = ModelReconciliationState.Load(modelReconciliationValue.GetDictionary(ModelReconciliationState.ShorthandProperty), context!.At("modelReconciliation")); } if (data.TryGetValue("pendingOutput", out var pendingOutputValue) && pendingOutputValue is not null) @@ -271,7 +280,7 @@ public static EngineCheckpoint Load(Dictionary data, LoadContex if (data.TryGetValue("pendingModelResponse", out var pendingModelResponseValue) && pendingModelResponseValue is not null) { - instance.PendingModelResponse = ModelInvocationResponse.Load(pendingModelResponseValue.GetDictionary(ModelInvocationResponse.ShorthandProperty), context); + instance.PendingModelResponse = ModelInvocationResponse.Load(pendingModelResponseValue.GetDictionary(ModelInvocationResponse.ShorthandProperty), context!.At("pendingModelResponse")); } if (data.TryGetValue("resumeSameIteration", out var resumeSameIterationValue) && resumeSameIterationValue is not null) @@ -286,7 +295,7 @@ public static EngineCheckpoint Load(Dictionary data, LoadContex if (data.TryGetValue("contextState", out var contextStateValue) && contextStateValue is not null) { - instance.ContextState = InvocationContextState.Load(contextStateValue.GetDictionary(InvocationContextState.ShorthandProperty), context); + instance.ContextState = InvocationContextState.Load(contextStateValue.GetDictionary(InvocationContextState.ShorthandProperty), context!.At("contextState")); } if (data.TryGetValue("metadata", out var metadataValue) && metadataValue is not null) @@ -309,46 +318,49 @@ public static IList LoadMessages(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'messages' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "messages" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'messages' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["role"] = kvp.Value }; - result.Add(Message.Load(newDict, context)); + result.Add(Message.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Message.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -363,46 +375,49 @@ public static IList LoadPendingToolRequests(object data, LoadC { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'pendingToolRequests' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "pendingToolRequests" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'pendingToolRequests' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(ModelToolRequest.Load(itemDict, context)); + result.Add(ModelToolRequest.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["id"] = kvp.Value }; - result.Add(ModelToolRequest.Load(newDict, context)); + result.Add(ModelToolRequest.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(ModelToolRequest.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(ModelToolRequest.Load(itemDict, context)); + result.Add(ModelToolRequest.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -417,46 +432,49 @@ public static IList LoadCompletedToolResults(object data, LoadC { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'completedToolResults' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "completedToolResults" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'completedToolResults' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(ModelToolResult.Load(itemDict, context)); + result.Add(ModelToolResult.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["requestId"] = kvp.Value }; - result.Add(ModelToolResult.Load(newDict, context)); + result.Add(ModelToolResult.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(ModelToolResult.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(ModelToolResult.Load(itemDict, context)); + result.Add(ModelToolResult.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -613,39 +631,8 @@ public static object SavePendingToolRequests(IList items, Save { context ??= new SaveContext(); - - if (context.CollectionFormat == "array") - { - return items.Select(item => item.Save(context)).ToList(); - } - - // Object format: use name as key - var result = new Dictionary(); - foreach (var item in items) - { - var itemData = item.Save(context); - if (itemData.TryGetValue("name", out var nameValue) && nameValue is string name) - { - itemData.Remove("name"); - - // Check if we can use shorthand - if (context.UseShorthand && ModelToolRequest.ShorthandProperty is string shorthandProp) - { - if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) - { - result[name] = itemData[shorthandProp]; - continue; - } - } - result[name] = itemData; - } - else - { - // No name, can't use object format for this item - throw new InvalidOperationException("Cannot save item in object format: missing 'name' property"); - } - } - return result; + // This collection type does not have a 'name' property, only array format is supported + return items.Select(item => item.Save(context)).ToList(); } @@ -657,39 +644,8 @@ public static object SaveCompletedToolResults(IList items, Save { context ??= new SaveContext(); - - if (context.CollectionFormat == "array") - { - return items.Select(item => item.Save(context)).ToList(); - } - - // Object format: use name as key - var result = new Dictionary(); - foreach (var item in items) - { - var itemData = item.Save(context); - if (itemData.TryGetValue("name", out var nameValue) && nameValue is string name) - { - itemData.Remove("name"); - - // Check if we can use shorthand - if (context.UseShorthand && ModelToolResult.ShorthandProperty is string shorthandProp) - { - if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) - { - result[name] = itemData[shorthandProp]; - continue; - } - } - result[name] = itemData; - } - else - { - // No name, can't use object format for this item - throw new InvalidOperationException("Cannot save item in object format: missing 'name' property"); - } - } - return result; + // This collection type does not have a 'name' property, only array format is supported + return items.Select(item => item.Save(context)).ToList(); } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/EngineDurabilityPort.cs b/runtime/csharp/Prompty.Core/Model/pipeline/EngineDurabilityPort.cs new file mode 100644 index 000000000..8cf27e165 --- /dev/null +++ b/runtime/csharp/Prompty.Core/Model/pipeline/EngineDurabilityPort.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) Microsoft. All rights reserved. +#nullable enable + +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0130 +namespace Prompty.Core; +#pragma warning restore IDE0130 + + /// + /// Persists semantic engine events and checkpoints without runtime cancellation. + /// + public interface IEngineDurabilityPort + { + /// + /// Append one semantic engine event durably + /// + Task AppendAsync(EngineEvent @event); + /// + /// Atomically append semantic engine events and persist the checkpoint that reflects them + /// + Task AppendWithCheckpointAsync(List events, EngineCheckpoint checkpoint); + } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/EngineEvent.cs b/runtime/csharp/Prompty.Core/Model/pipeline/EngineEvent.cs index 10d029233..722b91d8b 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/EngineEvent.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/EngineEvent.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -104,6 +107,7 @@ public EngineEvent() /// The loaded EngineEvent instance. public static EngineEvent Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -121,32 +125,32 @@ public static EngineEvent Load(Dictionary data, LoadContext? co if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("timestamp", out var timestampValue) && timestampValue is not null) { - instance.Timestamp = timestampValue?.ToString()!; + instance.Timestamp = timestampValue.ToString()!; } if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("runId", out var runIdValue) && runIdValue is not null) { - instance.RunId = runIdValue?.ToString()!; + instance.RunId = runIdValue.ToString()!; } if (data.TryGetValue("parentRunId", out var parentRunIdValue) && parentRunIdValue is not null) { - instance.ParentRunId = parentRunIdValue?.ToString()!; + instance.ParentRunId = parentRunIdValue.ToString()!; } if (data.TryGetValue("delegationDepth", out var delegationDepthValue) && delegationDepthValue is not null) @@ -156,7 +160,7 @@ public static EngineEvent Load(Dictionary data, LoadContext? co if (data.TryGetValue("invocationId", out var invocationIdValue) && invocationIdValue is not null) { - instance.InvocationId = invocationIdValue?.ToString()!; + instance.InvocationId = invocationIdValue.ToString()!; } if (data.TryGetValue("iteration", out var iterationValue) && iterationValue is not null) @@ -166,7 +170,7 @@ public static EngineEvent Load(Dictionary data, LoadContext? co if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = EngineEventKindParser.Parse(kindValue?.ToString()!); + instance.Kind = EngineEventKindParser.Parse(kindValue.ToString()!); } if (data.TryGetValue("payload", out var payloadValue) && payloadValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/EngineEventKind.cs b/runtime/csharp/Prompty.Core/Model/pipeline/EngineEventKind.cs index 713142f4a..f4b9c3168 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/EngineEventKind.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/EngineEventKind.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/EnginePermissionDecision.cs b/runtime/csharp/Prompty.Core/Model/pipeline/EnginePermissionDecision.cs index 6345798a3..3b3560f77 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/EnginePermissionDecision.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/EnginePermissionDecision.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -39,7 +42,7 @@ public EnginePermissionDecision() /// /// Opaque host-specific permission metadata /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } @@ -53,6 +56,7 @@ public EnginePermissionDecision() /// The loaded EnginePermissionDecision instance. public static EnginePermissionDecision Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -70,7 +74,7 @@ public static EnginePermissionDecision Load(Dictionary data, Lo if (data.TryGetValue("reason", out var reasonValue) && reasonValue is not null) { - instance.Reason = reasonValue?.ToString()!; + instance.Reason = reasonValue.ToString()!; } if (data.TryGetValue("metadata", out var metadataValue) && metadataValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/EnginePermissionPort.cs b/runtime/csharp/Prompty.Core/Model/pipeline/EnginePermissionPort.cs new file mode 100644 index 000000000..5050400e6 --- /dev/null +++ b/runtime/csharp/Prompty.Core/Model/pipeline/EnginePermissionPort.cs @@ -0,0 +1,21 @@ +// +// Copyright (c) Microsoft. All rights reserved. +#nullable enable + +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0130 +namespace Prompty.Core; +#pragma warning restore IDE0130 + + /// + /// Authorizes model-requested tools at a runtime cancellation boundary. + /// + public interface IEnginePermissionPort + { + /// + /// Authorize one model-requested tool before execution + /// + Task AuthorizeAsync(ModelToolRequest request, CancellationToken cancellationToken = default); + } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/EnginePostCommitPort.cs b/runtime/csharp/Prompty.Core/Model/pipeline/EnginePostCommitPort.cs new file mode 100644 index 000000000..02afd5bca --- /dev/null +++ b/runtime/csharp/Prompty.Core/Model/pipeline/EnginePostCommitPort.cs @@ -0,0 +1,21 @@ +// +// Copyright (c) Microsoft. All rights reserved. +#nullable enable + +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0130 +namespace Prompty.Core; +#pragma warning restore IDE0130 + + /// + /// Runs non-fatal host effects after a turn is durably committed. + /// + public interface IEnginePostCommitPort + { + /// + /// Run one idempotent host effect after the turn is durably committed + /// + Task AfterCommitAsync(string effectId, TurnCommit commit, CancellationToken cancellationToken = default); + } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/EngineToolPort.cs b/runtime/csharp/Prompty.Core/Model/pipeline/EngineToolPort.cs new file mode 100644 index 000000000..1e6111e4c --- /dev/null +++ b/runtime/csharp/Prompty.Core/Model/pipeline/EngineToolPort.cs @@ -0,0 +1,21 @@ +// +// Copyright (c) Microsoft. All rights reserved. +#nullable enable + +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0130 +namespace Prompty.Core; +#pragma warning restore IDE0130 + + /// + /// Executes authorized model-requested tools at a runtime cancellation boundary. + /// + public interface IEngineToolPort + { + /// + /// Execute one authorized model-requested tool + /// + Task ExecuteAsync(ModelToolRequest request, CancellationToken cancellationToken = default); + } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/EngineTurnStatus.cs b/runtime/csharp/Prompty.Core/Model/pipeline/EngineTurnStatus.cs index c16a39af8..51d916435 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/EngineTurnStatus.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/EngineTurnStatus.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/EventJournalWriter.cs b/runtime/csharp/Prompty.Core/Model/pipeline/EventJournalWriter.cs index 7f4c5dd31..e9f9e171c 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/EventJournalWriter.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/EventJournalWriter.cs @@ -1,5 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + +using System.Threading; +using System.Threading.Tasks; #pragma warning disable IDE0130 namespace Prompty.Core; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/EventSink.cs b/runtime/csharp/Prompty.Core/Model/pipeline/EventSink.cs index fb58472cb..3188e866a 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/EventSink.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/EventSink.cs @@ -1,5 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + +using System.Threading; +using System.Threading.Tasks; #pragma warning disable IDE0130 namespace Prompty.Core; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/Executor.cs b/runtime/csharp/Prompty.Core/Model/pipeline/Executor.cs index b641b4f56..828d8f18d 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/Executor.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/Executor.cs @@ -1,5 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + +using System.Threading; +using System.Threading.Tasks; #pragma warning disable IDE0130 namespace Prompty.Core; @@ -13,11 +17,11 @@ public interface IExecutor /// /// Call an LLM provider with messages and return the raw response /// - Task ExecuteAsync(Prompty agent, List messages); + Task ExecuteAsync(Prompty agent, List messages, CancellationToken cancellationToken = default); /// /// Call an LLM provider and return a streaming response. Returns a language-specific async iterable/stream of raw chunks. Not all providers support streaming; the default implementation should signal lack of support. /// - Task ExecuteStreamAsync(Prompty agent, List messages) => Task.FromResult(default!); + Task ExecuteStreamAsync(Prompty agent, List messages, CancellationToken cancellationToken = default) => Task.FromResult(default!); /// /// Format tool call results into messages for the next iteration /// diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/FinalOutputPolicyRequest.cs b/runtime/csharp/Prompty.Core/Model/pipeline/FinalOutputPolicyRequest.cs index 4048ff9be..cd233b562 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/FinalOutputPolicyRequest.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/FinalOutputPolicyRequest.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -68,6 +71,7 @@ public FinalOutputPolicyRequest() /// The loaded FinalOutputPolicyRequest instance. public static FinalOutputPolicyRequest Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -80,12 +84,12 @@ public static FinalOutputPolicyRequest Load(Dictionary data, Lo if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("iteration", out var iterationValue) && iterationValue is not null) @@ -95,7 +99,7 @@ public static FinalOutputPolicyRequest Load(Dictionary data, Lo if (data.TryGetValue("messages", out var messagesValue) && messagesValue is not null) { - instance.Messages = LoadMessages(messagesValue, context); + instance.Messages = LoadMessages(messagesValue, context!.At("messages")); } if (data.TryGetValue("output", out var outputValue) && outputValue is not null) @@ -123,46 +127,49 @@ public static IList LoadMessages(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'messages' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "messages" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'messages' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["role"] = kvp.Value }; - result.Add(Message.Load(newDict, context)); + result.Add(Message.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Message.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/FinalOutputPolicyResult.cs b/runtime/csharp/Prompty.Core/Model/pipeline/FinalOutputPolicyResult.cs index 56a8c0d3a..7e07cfc06 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/FinalOutputPolicyResult.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/FinalOutputPolicyResult.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -34,7 +37,7 @@ public FinalOutputPolicyResult() /// /// Opaque host-specific policy metadata /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } @@ -48,6 +51,7 @@ public FinalOutputPolicyResult() /// The loaded FinalOutputPolicyResult instance. public static FinalOutputPolicyResult Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/HostPolicyRequest.cs b/runtime/csharp/Prompty.Core/Model/pipeline/HostPolicyRequest.cs index 8c3037cad..4c9cf1e8b 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/HostPolicyRequest.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/HostPolicyRequest.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -68,6 +71,7 @@ public HostPolicyRequest() /// The loaded HostPolicyRequest instance. public static HostPolicyRequest Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -80,12 +84,12 @@ public static HostPolicyRequest Load(Dictionary data, LoadConte if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("iteration", out var iterationValue) && iterationValue is not null) @@ -95,7 +99,7 @@ public static HostPolicyRequest Load(Dictionary data, LoadConte if (data.TryGetValue("messages", out var messagesValue) && messagesValue is not null) { - instance.Messages = LoadMessages(messagesValue, context); + instance.Messages = LoadMessages(messagesValue, context!.At("messages")); } if (data.TryGetValue("stablePrefixMessages", out var stablePrefixMessagesValue) && stablePrefixMessagesValue is not null) @@ -123,46 +127,49 @@ public static IList LoadMessages(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'messages' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "messages" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'messages' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["role"] = kvp.Value }; - result.Add(Message.Load(newDict, context)); + result.Add(Message.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Message.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/HostPolicyResult.cs b/runtime/csharp/Prompty.Core/Model/pipeline/HostPolicyResult.cs index 74d2bf488..84e656b79 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/HostPolicyResult.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/HostPolicyResult.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -39,7 +42,7 @@ public HostPolicyResult() /// /// Opaque host-specific policy metadata /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } @@ -53,6 +56,7 @@ public HostPolicyResult() /// The loaded HostPolicyResult instance. public static HostPolicyResult Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,7 +69,7 @@ public static HostPolicyResult Load(Dictionary data, LoadContex if (data.TryGetValue("messages", out var messagesValue) && messagesValue is not null) { - instance.Messages = LoadMessages(messagesValue, context); + instance.Messages = LoadMessages(messagesValue, context!.At("messages")); } if (data.TryGetValue("stablePrefixMessages", out var stablePrefixMessagesValue) && stablePrefixMessagesValue is not null) @@ -93,46 +97,49 @@ public static IList LoadMessages(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'messages' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "messages" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'messages' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["role"] = kvp.Value }; - result.Add(Message.Load(newDict, context)); + result.Add(Message.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Message.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/HostToolExecutor.cs b/runtime/csharp/Prompty.Core/Model/pipeline/HostToolExecutor.cs index e51f2c348..e26b76fa0 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/HostToolExecutor.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/HostToolExecutor.cs @@ -1,5 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + +using System.Threading; +using System.Threading.Tasks; #pragma warning disable IDE0130 namespace Prompty.Core; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextDecision.cs b/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextDecision.cs index a49958a7b..c6d8ef39b 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextDecision.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextDecision.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -54,7 +57,7 @@ public InvocationContextDecision() /// /// Opaque host-specific decision metadata /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } @@ -68,6 +71,7 @@ public InvocationContextDecision() /// The loaded InvocationContextDecision instance. public static InvocationContextDecision Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -80,17 +84,17 @@ public static InvocationContextDecision Load(Dictionary data, L if (data.TryGetValue("candidateId", out var candidateIdValue) && candidateIdValue is not null) { - instance.CandidateId = candidateIdValue?.ToString()!; + instance.CandidateId = candidateIdValue.ToString()!; } if (data.TryGetValue("disposition", out var dispositionValue) && dispositionValue is not null) { - instance.Disposition = InvocationContextDispositionParser.Parse(dispositionValue?.ToString()!); + instance.Disposition = InvocationContextDispositionParser.Parse(dispositionValue.ToString()!); } if (data.TryGetValue("reason", out var reasonValue) && reasonValue is not null) { - instance.Reason = reasonValue?.ToString()!; + instance.Reason = reasonValue.ToString()!; } if (data.TryGetValue("rank", out var rankValue) && rankValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextDisposition.cs b/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextDisposition.cs index 5ddf80465..803d4aed8 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextDisposition.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextDisposition.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextPortability.cs b/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextPortability.cs index af74815b3..b3b5c3ebf 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextPortability.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextPortability.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextState.cs b/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextState.cs index 7f881229f..13ec324d6 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextState.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/InvocationContextState.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -34,7 +37,7 @@ public InvocationContextState() /// /// Explicit references to provider-held context state /// - public IList? DelegatedState { get; set; } + public IList? DelegatedState { get; set; } = []; @@ -48,6 +51,7 @@ public InvocationContextState() /// The loaded InvocationContextState instance. public static InvocationContextState Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -60,12 +64,12 @@ public static InvocationContextState Load(Dictionary data, Load if (data.TryGetValue("portability", out var portabilityValue) && portabilityValue is not null) { - instance.Portability = InvocationContextPortabilityParser.Parse(portabilityValue?.ToString()!); + instance.Portability = InvocationContextPortabilityParser.Parse(portabilityValue.ToString()!); } if (data.TryGetValue("delegatedState", out var delegatedStateValue) && delegatedStateValue is not null) { - instance.DelegatedState = LoadDelegatedState(delegatedStateValue, context); + instance.DelegatedState = LoadDelegatedState(delegatedStateValue, context!.At("delegatedState")); } if (context is not null) @@ -83,46 +87,49 @@ public static IList LoadDelegatedState(object data, Loa { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'delegatedState' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "delegatedState" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'delegatedState' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(DelegatedStateReference.Load(itemDict, context)); + result.Add(DelegatedStateReference.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["provider"] = kvp.Value }; - result.Add(DelegatedStateReference.Load(newDict, context)); + result.Add(DelegatedStateReference.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(DelegatedStateReference.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(DelegatedStateReference.Load(itemDict, context)); + result.Add(DelegatedStateReference.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ModelInvocationContextSnapshot.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ModelInvocationContextSnapshot.cs index 1dfa481c2..8d3727e39 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ModelInvocationContextSnapshot.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ModelInvocationContextSnapshot.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -61,7 +64,7 @@ public ModelInvocationContextSnapshot() /// /// Context-planning decisions used to assemble the snapshot /// - public IList? Decisions { get; set; } + public IList? Decisions { get; set; } = []; /// /// Number of leading messages eligible for provider prefix-cache reuse @@ -76,7 +79,7 @@ public ModelInvocationContextSnapshot() /// /// Opaque host-specific snapshot metadata /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } @@ -90,34 +93,40 @@ public ModelInvocationContextSnapshot() /// The loaded ModelInvocationContextSnapshot instance. public static ModelInvocationContextSnapshot Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("contextState", out var requiredContextStateValue) || requiredContextStateValue is null)) + { + throw new ArgumentException($"{context!.At("contextState").Path}: missing required field"); + } + // Create new instance var instance = new ModelInvocationContextSnapshot(); if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("invocationId", out var invocationIdValue) && invocationIdValue is not null) { - instance.InvocationId = invocationIdValue?.ToString()!; + instance.InvocationId = invocationIdValue.ToString()!; } if (data.TryGetValue("iteration", out var iterationValue) && iterationValue is not null) @@ -127,12 +136,12 @@ public static ModelInvocationContextSnapshot Load(Dictionary da if (data.TryGetValue("messages", out var messagesValue) && messagesValue is not null) { - instance.Messages = LoadMessages(messagesValue, context); + instance.Messages = LoadMessages(messagesValue, context!.At("messages")); } if (data.TryGetValue("decisions", out var decisionsValue) && decisionsValue is not null) { - instance.Decisions = LoadDecisions(decisionsValue, context); + instance.Decisions = LoadDecisions(decisionsValue, context!.At("decisions")); } if (data.TryGetValue("stablePrefixMessages", out var stablePrefixMessagesValue) && stablePrefixMessagesValue is not null) @@ -142,7 +151,7 @@ public static ModelInvocationContextSnapshot Load(Dictionary da if (data.TryGetValue("contextState", out var contextStateValue) && contextStateValue is not null) { - instance.ContextState = InvocationContextState.Load(contextStateValue.GetDictionary(InvocationContextState.ShorthandProperty), context); + instance.ContextState = InvocationContextState.Load(contextStateValue.GetDictionary(InvocationContextState.ShorthandProperty), context!.At("contextState")); } if (data.TryGetValue("metadata", out var metadataValue) && metadataValue is not null) @@ -165,46 +174,49 @@ public static IList LoadMessages(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'messages' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "messages" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'messages' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["role"] = kvp.Value }; - result.Add(Message.Load(newDict, context)); + result.Add(Message.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Message.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -219,46 +231,49 @@ public static IList LoadDecisions(object data, LoadCo { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'decisions' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "decisions" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'decisions' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(InvocationContextDecision.Load(itemDict, context)); + result.Add(InvocationContextDecision.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["candidateId"] = kvp.Value }; - result.Add(InvocationContextDecision.Load(newDict, context)); + result.Add(InvocationContextDecision.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(InvocationContextDecision.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(InvocationContextDecision.Load(itemDict, context)); + result.Add(InvocationContextDecision.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ModelInvocationRequest.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ModelInvocationRequest.cs index c5a247fff..3d6dc0630 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ModelInvocationRequest.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ModelInvocationRequest.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -43,19 +46,25 @@ public ModelInvocationRequest() /// The loaded ModelInvocationRequest instance. public static ModelInvocationRequest Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("context", out var requiredContextValue) || requiredContextValue is null)) + { + throw new ArgumentException($"{context!.At("context").Path}: missing required field"); + } + // Create new instance var instance = new ModelInvocationRequest(); if (data.TryGetValue("context", out var contextValue) && contextValue is not null) { - instance.Context = ModelInvocationContextSnapshot.Load(contextValue.GetDictionary(ModelInvocationContextSnapshot.ShorthandProperty), context); + instance.Context = ModelInvocationContextSnapshot.Load(contextValue.GetDictionary(ModelInvocationContextSnapshot.ShorthandProperty), context!.At("context")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ModelInvocationResponse.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ModelInvocationResponse.cs index 7b091bddd..2a8281183 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ModelInvocationResponse.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ModelInvocationResponse.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -43,12 +46,12 @@ public ModelInvocationResponse() /// /// Assistant messages to commit before tool results are made visible /// - public IList? AssistantMessages { get; set; } + public IList? AssistantMessages { get; set; } = []; /// /// Tool requests returned by the provider /// - public IList? ToolRequests { get; set; } + public IList? ToolRequests { get; set; } = []; /// /// Provider-context state to carry into the next invocation @@ -58,7 +61,7 @@ public ModelInvocationResponse() /// /// Opaque provider-specific response metadata /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } @@ -72,6 +75,7 @@ public ModelInvocationResponse() /// The loaded ModelInvocationResponse instance. public static ModelInvocationResponse Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -89,22 +93,22 @@ public static ModelInvocationResponse Load(Dictionary data, Loa if (data.TryGetValue("usage", out var usageValue) && usageValue is not null) { - instance.Usage = InvocationUsage.Load(usageValue.GetDictionary(InvocationUsage.ShorthandProperty), context); + instance.Usage = InvocationUsage.Load(usageValue.GetDictionary(InvocationUsage.ShorthandProperty), context!.At("usage")); } if (data.TryGetValue("assistantMessages", out var assistantMessagesValue) && assistantMessagesValue is not null) { - instance.AssistantMessages = LoadAssistantMessages(assistantMessagesValue, context); + instance.AssistantMessages = LoadAssistantMessages(assistantMessagesValue, context!.At("assistantMessages")); } if (data.TryGetValue("toolRequests", out var toolRequestsValue) && toolRequestsValue is not null) { - instance.ToolRequests = LoadToolRequests(toolRequestsValue, context); + instance.ToolRequests = LoadToolRequests(toolRequestsValue, context!.At("toolRequests")); } if (data.TryGetValue("nextContextState", out var nextContextStateValue) && nextContextStateValue is not null) { - instance.NextContextState = InvocationContextState.Load(nextContextStateValue.GetDictionary(InvocationContextState.ShorthandProperty), context); + instance.NextContextState = InvocationContextState.Load(nextContextStateValue.GetDictionary(InvocationContextState.ShorthandProperty), context!.At("nextContextState")); } if (data.TryGetValue("metadata", out var metadataValue) && metadataValue is not null) @@ -127,46 +131,49 @@ public static IList LoadAssistantMessages(object data, LoadContext? con { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'assistantMessages' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "assistantMessages" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'assistantMessages' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["role"] = kvp.Value }; - result.Add(Message.Load(newDict, context)); + result.Add(Message.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Message.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -181,46 +188,49 @@ public static IList LoadToolRequests(object data, LoadContext? { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'toolRequests' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "toolRequests" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'toolRequests' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(ModelToolRequest.Load(itemDict, context)); + result.Add(ModelToolRequest.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["id"] = kvp.Value }; - result.Add(ModelToolRequest.Load(newDict, context)); + result.Add(ModelToolRequest.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(ModelToolRequest.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(ModelToolRequest.Load(itemDict, context)); + result.Add(ModelToolRequest.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -314,39 +324,8 @@ public static object SaveToolRequests(IList items, SaveContext { context ??= new SaveContext(); - - if (context.CollectionFormat == "array") - { - return items.Select(item => item.Save(context)).ToList(); - } - - // Object format: use name as key - var result = new Dictionary(); - foreach (var item in items) - { - var itemData = item.Save(context); - if (itemData.TryGetValue("name", out var nameValue) && nameValue is string name) - { - itemData.Remove("name"); - - // Check if we can use shorthand - if (context.UseShorthand && ModelToolRequest.ShorthandProperty is string shorthandProp) - { - if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) - { - result[name] = itemData[shorthandProp]; - continue; - } - } - result[name] = itemData; - } - else - { - // No name, can't use object format for this item - throw new InvalidOperationException("Cannot save item in object format: missing 'name' property"); - } - } - return result; + // This collection type does not have a 'name' property, only array format is supported + return items.Select(item => item.Save(context)).ToList(); } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ModelReconciliationState.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ModelReconciliationState.cs index 46f09519a..1dc8a1212 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ModelReconciliationState.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ModelReconciliationState.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,7 +56,7 @@ public ModelReconciliationState() /// /// Opaque host-specific reconciliation metadata /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } @@ -67,24 +70,30 @@ public ModelReconciliationState() /// The loaded ModelReconciliationState instance. public static ModelReconciliationState Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("request", out var requiredRequestValue) || requiredRequestValue is null)) + { + throw new ArgumentException($"{context!.At("request").Path}: missing required field"); + } + // Create new instance var instance = new ModelReconciliationState(); if (data.TryGetValue("invocationId", out var invocationIdValue) && invocationIdValue is not null) { - instance.InvocationId = invocationIdValue?.ToString()!; + instance.InvocationId = invocationIdValue.ToString()!; } if (data.TryGetValue("request", out var requestValue) && requestValue is not null) { - instance.Request = ModelInvocationRequest.Load(requestValue.GetDictionary(ModelInvocationRequest.ShorthandProperty), context); + instance.Request = ModelInvocationRequest.Load(requestValue.GetDictionary(ModelInvocationRequest.ShorthandProperty), context!.At("request")); } if (data.TryGetValue("failedAttempt", out var failedAttemptValue) && failedAttemptValue is not null) @@ -94,7 +103,7 @@ public static ModelReconciliationState Load(Dictionary data, Lo if (data.TryGetValue("message", out var messageValue) && messageValue is not null) { - instance.Message = messageValue?.ToString()!; + instance.Message = messageValue.ToString()!; } if (data.TryGetValue("metadata", out var metadataValue) && metadataValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ModelToolOutcome.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ModelToolOutcome.cs index 3f94c3be8..5205dce66 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ModelToolOutcome.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ModelToolOutcome.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ModelToolRequest.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ModelToolRequest.cs index edc6f7312..17d3024ef 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ModelToolRequest.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ModelToolRequest.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,7 +51,7 @@ public ModelToolRequest() /// /// Opaque provider-specific tool request metadata /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } @@ -62,6 +65,7 @@ public ModelToolRequest() /// The loaded ModelToolRequest instance. public static ModelToolRequest Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -74,12 +78,12 @@ public static ModelToolRequest Load(Dictionary data, LoadContex if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("arguments", out var argumentsValue) && argumentsValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ModelToolResult.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ModelToolResult.cs index e8944b152..c60f0f855 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ModelToolResult.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ModelToolResult.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -54,7 +57,7 @@ public ModelToolResult() /// /// Opaque host-specific tool result metadata /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } @@ -68,6 +71,7 @@ public ModelToolResult() /// The loaded ModelToolResult instance. public static ModelToolResult Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -80,17 +84,17 @@ public static ModelToolResult Load(Dictionary data, LoadContext if (data.TryGetValue("requestId", out var requestIdValue) && requestIdValue is not null) { - instance.RequestId = requestIdValue?.ToString()!; + instance.RequestId = requestIdValue.ToString()!; } if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("outcome", out var outcomeValue) && outcomeValue is not null) { - instance.Outcome = ModelToolOutcomeParser.Parse(outcomeValue?.ToString()!); + instance.Outcome = ModelToolOutcomeParser.Parse(outcomeValue.ToString()!); } if (data.TryGetValue("output", out var outputValue) && outputValue is not null) @@ -100,7 +104,7 @@ public static ModelToolResult Load(Dictionary data, LoadContext if (data.TryGetValue("errorKind", out var errorKindValue) && errorKindValue is not null) { - instance.ErrorKind = errorKindValue?.ToString()!; + instance.ErrorKind = errorKindValue.ToString()!; } if (data.TryGetValue("metadata", out var metadataValue) && metadataValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/Parser.cs b/runtime/csharp/Prompty.Core/Model/pipeline/Parser.cs index 46167af1b..6ab40d31f 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/Parser.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/Parser.cs @@ -1,5 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + +using System.Threading; +using System.Threading.Tasks; #pragma warning disable IDE0130 namespace Prompty.Core; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/PermissionResolver.cs b/runtime/csharp/Prompty.Core/Model/pipeline/PermissionResolver.cs index 721178ff5..a500b5856 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/PermissionResolver.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/PermissionResolver.cs @@ -1,5 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + +using System.Threading; +using System.Threading.Tasks; #pragma warning disable IDE0130 namespace Prompty.Core; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/Processor.cs b/runtime/csharp/Prompty.Core/Model/pipeline/Processor.cs index 8153b8280..9fc2b0cd5 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/Processor.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/Processor.cs @@ -1,5 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + +using System.Threading; +using System.Threading.Tasks; #pragma warning disable IDE0130 namespace Prompty.Core; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/Renderer.cs b/runtime/csharp/Prompty.Core/Model/pipeline/Renderer.cs index 232d42c75..560b70a12 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/Renderer.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/Renderer.cs @@ -1,5 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + +using System.Threading; +using System.Threading.Tasks; #pragma warning disable IDE0130 namespace Prompty.Core; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ReplayJournalRecord.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ReplayJournalRecord.cs index bcacbd475..205aa3851 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ReplayJournalRecord.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ReplayJournalRecord.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -104,6 +107,7 @@ public ReplayJournalRecord() /// The loaded ReplayJournalRecord instance. public static ReplayJournalRecord Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -116,22 +120,22 @@ public static ReplayJournalRecord Load(Dictionary data, LoadCon if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = ReplayRecordKindParser.Parse(kindValue?.ToString()!); + instance.Kind = ReplayRecordKindParser.Parse(kindValue.ToString()!); } if (data.TryGetValue("type", out var typeValue) && typeValue is not null) { - instance.Type = typeValue?.ToString()!; + instance.Type = typeValue.ToString()!; } if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("iteration", out var iterationValue) && iterationValue is not null) @@ -141,17 +145,17 @@ public static ReplayJournalRecord Load(Dictionary data, LoadCon if (data.TryGetValue("status", out var statusValue) && statusValue is not null) { - instance.Status = ReplayRecordStatusParser.Parse(statusValue?.ToString()!); + instance.Status = ReplayRecordStatusParser.Parse(statusValue.ToString()!); } if (data.TryGetValue("requestId", out var requestIdValue) && requestIdValue is not null) { - instance.RequestId = requestIdValue?.ToString()!; + instance.RequestId = requestIdValue.ToString()!; } if (data.TryGetValue("toolName", out var toolNameValue) && toolNameValue is not null) { - instance.ToolName = toolNameValue?.ToString()!; + instance.ToolName = toolNameValue.ToString()!; } if (data.TryGetValue("success", out var successValue) && successValue is not null) @@ -161,7 +165,7 @@ public static ReplayJournalRecord Load(Dictionary data, LoadCon if (data.TryGetValue("errorKind", out var errorKindValue) && errorKindValue is not null) { - instance.ErrorKind = errorKindValue?.ToString()!; + instance.ErrorKind = errorKindValue.ToString()!; } if (data.TryGetValue("turns", out var turnsValue) && turnsValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ReplayMismatch.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ReplayMismatch.cs index 8d5ac8b5f..427a9e265 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ReplayMismatch.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ReplayMismatch.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -58,6 +61,7 @@ public ReplayMismatch() /// The loaded ReplayMismatch instance. public static ReplayMismatch Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -75,17 +79,17 @@ public static ReplayMismatch Load(Dictionary data, LoadContext? if (data.TryGetValue("expected", out var expectedValue) && expectedValue is not null) { - instance.Expected = ReplayJournalRecord.Load(expectedValue.GetDictionary(ReplayJournalRecord.ShorthandProperty), context); + instance.Expected = ReplayJournalRecord.Load(expectedValue.GetDictionary(ReplayJournalRecord.ShorthandProperty), context!.At("expected")); } if (data.TryGetValue("actual", out var actualValue) && actualValue is not null) { - instance.Actual = ReplayJournalRecord.Load(actualValue.GetDictionary(ReplayJournalRecord.ShorthandProperty), context); + instance.Actual = ReplayJournalRecord.Load(actualValue.GetDictionary(ReplayJournalRecord.ShorthandProperty), context!.At("actual")); } if (data.TryGetValue("message", out var messageValue) && messageValue is not null) { - instance.Message = messageValue?.ToString()!; + instance.Message = messageValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ReplayRecordKind.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ReplayRecordKind.cs index de3d49401..958bb0dfa 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ReplayRecordKind.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ReplayRecordKind.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ReplayRecordStatus.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ReplayRecordStatus.cs index 4745830f6..e8996729d 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ReplayRecordStatus.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ReplayRecordStatus.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ReplayVerificationRequest.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ReplayVerificationRequest.cs index 3714f11ef..67236614a 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ReplayVerificationRequest.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ReplayVerificationRequest.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,6 +51,7 @@ public ReplayVerificationRequest() /// The loaded ReplayVerificationRequest instance. public static ReplayVerificationRequest Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -60,12 +64,12 @@ public static ReplayVerificationRequest Load(Dictionary data, L if (data.TryGetValue("expected", out var expectedValue) && expectedValue is not null) { - instance.Expected = LoadExpected(expectedValue, context); + instance.Expected = LoadExpected(expectedValue, context!.At("expected")); } if (data.TryGetValue("actual", out var actualValue) && actualValue is not null) { - instance.Actual = LoadActual(actualValue, context); + instance.Actual = LoadActual(actualValue, context!.At("actual")); } if (context is not null) @@ -83,46 +87,49 @@ public static IList LoadExpected(object data, LoadContext? { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'expected' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "expected" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'expected' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(ReplayJournalRecord.Load(itemDict, context)); + result.Add(ReplayJournalRecord.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["kind"] = kvp.Value }; - result.Add(ReplayJournalRecord.Load(newDict, context)); + result.Add(ReplayJournalRecord.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(ReplayJournalRecord.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(ReplayJournalRecord.Load(itemDict, context)); + result.Add(ReplayJournalRecord.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -137,46 +144,49 @@ public static IList LoadActual(object data, LoadContext? co { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'actual' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "actual" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'actual' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(ReplayJournalRecord.Load(itemDict, context)); + result.Add(ReplayJournalRecord.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["kind"] = kvp.Value }; - result.Add(ReplayJournalRecord.Load(newDict, context)); + result.Add(ReplayJournalRecord.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(ReplayJournalRecord.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(ReplayJournalRecord.Load(itemDict, context)); + result.Add(ReplayJournalRecord.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ReplayVerificationResult.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ReplayVerificationResult.cs index 5262d14a9..ec38f8363 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ReplayVerificationResult.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ReplayVerificationResult.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -34,7 +37,7 @@ public ReplayVerificationResult() /// /// Record mismatches, empty when verification passed /// - public IList? Mismatches { get; set; } + public IList? Mismatches { get; set; } = []; /// /// Number of expected records @@ -58,6 +61,7 @@ public ReplayVerificationResult() /// The loaded ReplayVerificationResult instance. public static ReplayVerificationResult Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -70,12 +74,12 @@ public static ReplayVerificationResult Load(Dictionary data, Lo if (data.TryGetValue("status", out var statusValue) && statusValue is not null) { - instance.Status = ReplayVerificationStatusParser.Parse(statusValue?.ToString()!); + instance.Status = ReplayVerificationStatusParser.Parse(statusValue.ToString()!); } if (data.TryGetValue("mismatches", out var mismatchesValue) && mismatchesValue is not null) { - instance.Mismatches = LoadMismatches(mismatchesValue, context); + instance.Mismatches = LoadMismatches(mismatchesValue, context!.At("mismatches")); } if (data.TryGetValue("expectedCount", out var expectedCountValue) && expectedCountValue is not null) @@ -103,46 +107,49 @@ public static IList LoadMismatches(object data, LoadContext? con { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'mismatches' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "mismatches" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'mismatches' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(ReplayMismatch.Load(itemDict, context)); + result.Add(ReplayMismatch.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["index"] = kvp.Value }; - result.Add(ReplayMismatch.Load(newDict, context)); + result.Add(ReplayMismatch.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(ReplayMismatch.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(ReplayMismatch.Load(itemDict, context)); + result.Add(ReplayMismatch.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ReplayVerificationStatus.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ReplayVerificationStatus.cs index 777917783..6b33b2428 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ReplayVerificationStatus.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ReplayVerificationStatus.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/ResumeContext.cs b/runtime/csharp/Prompty.Core/Model/pipeline/ResumeContext.cs index 824290176..5bdaf46a5 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/ResumeContext.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/ResumeContext.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,7 +56,7 @@ public ResumeContext() /// /// Opaque host-specific resume metadata /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } @@ -67,19 +70,25 @@ public ResumeContext() /// The loaded ResumeContext instance. public static ResumeContext Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("checkpoint", out var requiredCheckpointValue) || requiredCheckpointValue is null)) + { + throw new ArgumentException($"{context!.At("checkpoint").Path}: missing required field"); + } + // Create new instance var instance = new ResumeContext(); if (data.TryGetValue("checkpoint", out var checkpointValue) && checkpointValue is not null) { - instance.Checkpoint = EngineCheckpoint.Load(checkpointValue.GetDictionary(EngineCheckpoint.ShorthandProperty), context); + instance.Checkpoint = EngineCheckpoint.Load(checkpointValue.GetDictionary(EngineCheckpoint.ShorthandProperty), context!.At("checkpoint")); } if (data.TryGetValue("maxIterations", out var maxIterationsValue) && maxIterationsValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/RetryPolicyRequest.cs b/runtime/csharp/Prompty.Core/Model/pipeline/RetryPolicyRequest.cs index b78a41628..3ced460b7 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/RetryPolicyRequest.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/RetryPolicyRequest.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -58,6 +61,7 @@ public RetryPolicyRequest() /// The loaded RetryPolicyRequest instance. public static RetryPolicyRequest Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -85,7 +89,7 @@ public static RetryPolicyRequest Load(Dictionary data, LoadCont if (data.TryGetValue("reason", out var reasonValue) && reasonValue is not null) { - instance.Reason = reasonValue?.ToString()!; + instance.Reason = reasonValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/RunTurnRequest.cs b/runtime/csharp/Prompty.Core/Model/pipeline/RunTurnRequest.cs index 168b5c9bf..89d51570b 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/RunTurnRequest.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/RunTurnRequest.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -37,9 +40,9 @@ public RunTurnRequest() public string TurnId { get; set; } = string.Empty; /// - /// Inputs supplied to the deterministic single-turn run + /// Inputs supplied to the deterministic single-turn run. Values may be explicit null. /// - public IDictionary? Inputs { get; set; } + public IDictionary? Inputs { get; set; } /// /// Canonical turn execution options @@ -58,6 +61,7 @@ public RunTurnRequest() /// The loaded RunTurnRequest instance. public static RunTurnRequest Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -70,12 +74,12 @@ public static RunTurnRequest Load(Dictionary data, LoadContext? if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("inputs", out var inputsValue) && inputsValue is not null) @@ -85,7 +89,7 @@ public static RunTurnRequest Load(Dictionary data, LoadContext? if (data.TryGetValue("options", out var optionsValue) && optionsValue is not null) { - instance.Options = TurnOptions.Load(optionsValue.GetDictionary(TurnOptions.ShorthandProperty), context); + instance.Options = TurnOptions.Load(optionsValue.GetDictionary(TurnOptions.ShorthandProperty), context!.At("options")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/RunTurnResult.cs b/runtime/csharp/Prompty.Core/Model/pipeline/RunTurnResult.cs index b85951b3b..82a1ee5e9 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/RunTurnResult.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/RunTurnResult.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -54,12 +57,12 @@ public RunTurnResult() /// /// Host tool results produced during the turn /// - public IList? ToolResults { get; set; } + public IList? ToolResults { get; set; } = []; /// /// Checkpoints created during the turn /// - public IList? Checkpoints { get; set; } + public IList? Checkpoints { get; set; } = []; @@ -73,6 +76,7 @@ public RunTurnResult() /// The loaded RunTurnResult instance. public static RunTurnResult Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -85,17 +89,17 @@ public static RunTurnResult Load(Dictionary data, LoadContext? if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("status", out var statusValue) && statusValue is not null) { - instance.Status = RunTurnStatusParser.Parse(statusValue?.ToString()!); + instance.Status = RunTurnStatusParser.Parse(statusValue.ToString()!); } if (data.TryGetValue("output", out var outputValue) && outputValue is not null) @@ -110,12 +114,12 @@ public static RunTurnResult Load(Dictionary data, LoadContext? if (data.TryGetValue("toolResults", out var toolResultsValue) && toolResultsValue is not null) { - instance.ToolResults = LoadToolResults(toolResultsValue, context); + instance.ToolResults = LoadToolResults(toolResultsValue, context!.At("toolResults")); } if (data.TryGetValue("checkpoints", out var checkpointsValue) && checkpointsValue is not null) { - instance.Checkpoints = LoadCheckpoints(checkpointsValue, context); + instance.Checkpoints = LoadCheckpoints(checkpointsValue, context!.At("checkpoints")); } if (context is not null) @@ -133,46 +137,49 @@ public static IList LoadToolResults(object data, LoadContext? co { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'toolResults' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "toolResults" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'toolResults' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(HostToolResult.Load(itemDict, context)); + result.Add(HostToolResult.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["requestId"] = kvp.Value }; - result.Add(HostToolResult.Load(newDict, context)); + result.Add(HostToolResult.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(HostToolResult.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(HostToolResult.Load(itemDict, context)); + result.Add(HostToolResult.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -187,46 +194,49 @@ public static IList LoadCheckpoints(object data, LoadContext? contex { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'checkpoints' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "checkpoints" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'checkpoints' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Checkpoint.Load(itemDict, context)); + result.Add(Checkpoint.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["id"] = kvp.Value }; - result.Add(Checkpoint.Load(newDict, context)); + result.Add(Checkpoint.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Checkpoint.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Checkpoint.Load(itemDict, context)); + result.Add(Checkpoint.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/RunTurnStatus.cs b/runtime/csharp/Prompty.Core/Model/pipeline/RunTurnStatus.cs index a0162b6f0..ed7aa03c0 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/RunTurnStatus.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/RunTurnStatus.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/TurnCommit.cs b/runtime/csharp/Prompty.Core/Model/pipeline/TurnCommit.cs index b2f8b5f59..5301bf007 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/TurnCommit.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/TurnCommit.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -83,29 +86,35 @@ public TurnCommit() /// The loaded TurnCommit instance. public static TurnCommit Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("contextState", out var requiredContextStateValue) || requiredContextStateValue is null)) + { + throw new ArgumentException($"{context!.At("contextState").Path}: missing required field"); + } + // Create new instance var instance = new TurnCommit(); if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("status", out var statusValue) && statusValue is not null) { - instance.Status = EngineTurnStatusParser.Parse(statusValue?.ToString()!); + instance.Status = EngineTurnStatusParser.Parse(statusValue.ToString()!); } if (data.TryGetValue("output", out var outputValue) && outputValue is not null) @@ -115,7 +124,7 @@ public static TurnCommit Load(Dictionary data, LoadContext? con if (data.TryGetValue("messages", out var messagesValue) && messagesValue is not null) { - instance.Messages = LoadMessages(messagesValue, context); + instance.Messages = LoadMessages(messagesValue, context!.At("messages")); } if (data.TryGetValue("iterations", out var iterationsValue) && iterationsValue is not null) @@ -130,12 +139,12 @@ public static TurnCommit Load(Dictionary data, LoadContext? con if (data.TryGetValue("contextState", out var contextStateValue) && contextStateValue is not null) { - instance.ContextState = InvocationContextState.Load(contextStateValue.GetDictionary(InvocationContextState.ShorthandProperty), context); + instance.ContextState = InvocationContextState.Load(contextStateValue.GetDictionary(InvocationContextState.ShorthandProperty), context!.At("contextState")); } if (data.TryGetValue("modelReconciliation", out var modelReconciliationValue) && modelReconciliationValue is not null) { - instance.ModelReconciliation = ModelReconciliationState.Load(modelReconciliationValue.GetDictionary(ModelReconciliationState.ShorthandProperty), context); + instance.ModelReconciliation = ModelReconciliationState.Load(modelReconciliationValue.GetDictionary(ModelReconciliationState.ShorthandProperty), context!.At("modelReconciliation")); } if (context is not null) @@ -153,46 +162,49 @@ public static IList LoadMessages(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'messages' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "messages" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'messages' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["role"] = kvp.Value }; - result.Add(Message.Load(newDict, context)); + result.Add(Message.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Message.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/TurnEngineResult.cs b/runtime/csharp/Prompty.Core/Model/pipeline/TurnEngineResult.cs index f7c4ec95b..6703be847 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/TurnEngineResult.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/TurnEngineResult.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -34,12 +37,12 @@ public TurnEngineResult() /// /// Immutable context snapshots produced during the turn /// - public IList? Snapshots { get; set; } + public IList? Snapshots { get; set; } = []; /// /// Normalized tool results produced during the turn /// - public IList? ToolResults { get; set; } + public IList? ToolResults { get; set; } = []; /// /// A non-fatal post-commit failure; the turn itself remains committed @@ -58,34 +61,40 @@ public TurnEngineResult() /// The loaded TurnEngineResult instance. public static TurnEngineResult Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("commit", out var requiredCommitValue) || requiredCommitValue is null)) + { + throw new ArgumentException($"{context!.At("commit").Path}: missing required field"); + } + // Create new instance var instance = new TurnEngineResult(); if (data.TryGetValue("commit", out var commitValue) && commitValue is not null) { - instance.Commit = TurnCommit.Load(commitValue.GetDictionary(TurnCommit.ShorthandProperty), context); + instance.Commit = TurnCommit.Load(commitValue.GetDictionary(TurnCommit.ShorthandProperty), context!.At("commit")); } if (data.TryGetValue("snapshots", out var snapshotsValue) && snapshotsValue is not null) { - instance.Snapshots = LoadSnapshots(snapshotsValue, context); + instance.Snapshots = LoadSnapshots(snapshotsValue, context!.At("snapshots")); } if (data.TryGetValue("toolResults", out var toolResultsValue) && toolResultsValue is not null) { - instance.ToolResults = LoadToolResults(toolResultsValue, context); + instance.ToolResults = LoadToolResults(toolResultsValue, context!.At("toolResults")); } if (data.TryGetValue("postCommitError", out var postCommitErrorValue) && postCommitErrorValue is not null) { - instance.PostCommitError = postCommitErrorValue?.ToString()!; + instance.PostCommitError = postCommitErrorValue.ToString()!; } if (context is not null) @@ -103,46 +112,49 @@ public static IList LoadSnapshots(object data, L { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'snapshots' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "snapshots" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'snapshots' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(ModelInvocationContextSnapshot.Load(itemDict, context)); + result.Add(ModelInvocationContextSnapshot.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["id"] = kvp.Value }; - result.Add(ModelInvocationContextSnapshot.Load(newDict, context)); + result.Add(ModelInvocationContextSnapshot.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(ModelInvocationContextSnapshot.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(ModelInvocationContextSnapshot.Load(itemDict, context)); + result.Add(ModelInvocationContextSnapshot.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -157,46 +169,49 @@ public static IList LoadToolResults(object data, LoadContext? c { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'toolResults' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "toolResults" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'toolResults' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(ModelToolResult.Load(itemDict, context)); + result.Add(ModelToolResult.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["requestId"] = kvp.Value }; - result.Add(ModelToolResult.Load(newDict, context)); + result.Add(ModelToolResult.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(ModelToolResult.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(ModelToolResult.Load(itemDict, context)); + result.Add(ModelToolResult.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -275,39 +290,8 @@ public static object SaveToolResults(IList items, SaveContext? { context ??= new SaveContext(); - - if (context.CollectionFormat == "array") - { - return items.Select(item => item.Save(context)).ToList(); - } - - // Object format: use name as key - var result = new Dictionary(); - foreach (var item in items) - { - var itemData = item.Save(context); - if (itemData.TryGetValue("name", out var nameValue) && nameValue is string name) - { - itemData.Remove("name"); - - // Check if we can use shorthand - if (context.UseShorthand && ModelToolResult.ShorthandProperty is string shorthandProp) - { - if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) - { - result[name] = itemData[shorthandProp]; - continue; - } - } - result[name] = itemData; - } - else - { - // No name, can't use object format for this item - throw new InvalidOperationException("Cannot save item in object format: missing 'name' property"); - } - } - return result; + // This collection type does not have a 'name' property, only array format is supported + return items.Select(item => item.Save(context)).ToList(); } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/TurnModelRequest.cs b/runtime/csharp/Prompty.Core/Model/pipeline/TurnModelRequest.cs index 31a4b2236..3bc58fc3a 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/TurnModelRequest.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/TurnModelRequest.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -46,9 +49,9 @@ public TurnModelRequest() public int Iteration { get; set; } /// - /// Inputs supplied to the deterministic single-turn run + /// Inputs supplied to the deterministic single-turn run. Values may be explicit null. /// - public IDictionary? Inputs { get; set; } + public IDictionary? Inputs { get; set; } /// /// Canonical turn execution options @@ -58,7 +61,7 @@ public TurnModelRequest() /// /// Host tool results produced by the previous iteration /// - public IList? ToolResults { get; set; } + public IList? ToolResults { get; set; } = []; @@ -72,6 +75,7 @@ public TurnModelRequest() /// The loaded TurnModelRequest instance. public static TurnModelRequest Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -84,12 +88,12 @@ public static TurnModelRequest Load(Dictionary data, LoadContex if (data.TryGetValue("sessionId", out var sessionIdValue) && sessionIdValue is not null) { - instance.SessionId = sessionIdValue?.ToString()!; + instance.SessionId = sessionIdValue.ToString()!; } if (data.TryGetValue("turnId", out var turnIdValue) && turnIdValue is not null) { - instance.TurnId = turnIdValue?.ToString()!; + instance.TurnId = turnIdValue.ToString()!; } if (data.TryGetValue("iteration", out var iterationValue) && iterationValue is not null) @@ -104,12 +108,12 @@ public static TurnModelRequest Load(Dictionary data, LoadContex if (data.TryGetValue("options", out var optionsValue) && optionsValue is not null) { - instance.Options = TurnOptions.Load(optionsValue.GetDictionary(TurnOptions.ShorthandProperty), context); + instance.Options = TurnOptions.Load(optionsValue.GetDictionary(TurnOptions.ShorthandProperty), context!.At("options")); } if (data.TryGetValue("toolResults", out var toolResultsValue) && toolResultsValue is not null) { - instance.ToolResults = LoadToolResults(toolResultsValue, context); + instance.ToolResults = LoadToolResults(toolResultsValue, context!.At("toolResults")); } if (context is not null) @@ -127,46 +131,49 @@ public static IList LoadToolResults(object data, LoadContext? co { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'toolResults' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "toolResults" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'toolResults' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(HostToolResult.Load(itemDict, context)); + result.Add(HostToolResult.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["requestId"] = kvp.Value }; - result.Add(HostToolResult.Load(newDict, context)); + result.Add(HostToolResult.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(HostToolResult.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(HostToolResult.Load(itemDict, context)); + result.Add(HostToolResult.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/TurnModelResponse.cs b/runtime/csharp/Prompty.Core/Model/pipeline/TurnModelResponse.cs index 247092540..278d2594b 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/TurnModelResponse.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/TurnModelResponse.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -39,12 +42,12 @@ public TurnModelResponse() /// /// Host tool execution requests emitted by the model callback /// - public IList? ToolRequests { get; set; } + public IList? ToolRequests { get; set; } = []; /// - /// Additional deterministic state to merge into the iteration checkpoint + /// Additional deterministic state to merge into the iteration checkpoint. Values may be explicit null. /// - public IDictionary? CheckpointState { get; set; } + public IDictionary? CheckpointState { get; set; } @@ -58,6 +61,7 @@ public TurnModelResponse() /// The loaded TurnModelResponse instance. public static TurnModelResponse Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -75,12 +79,12 @@ public static TurnModelResponse Load(Dictionary data, LoadConte if (data.TryGetValue("usage", out var usageValue) && usageValue is not null) { - instance.Usage = InvocationUsage.Load(usageValue.GetDictionary(InvocationUsage.ShorthandProperty), context); + instance.Usage = InvocationUsage.Load(usageValue.GetDictionary(InvocationUsage.ShorthandProperty), context!.At("usage")); } if (data.TryGetValue("toolRequests", out var toolRequestsValue) && toolRequestsValue is not null) { - instance.ToolRequests = LoadToolRequests(toolRequestsValue, context); + instance.ToolRequests = LoadToolRequests(toolRequestsValue, context!.At("toolRequests")); } if (data.TryGetValue("checkpointState", out var checkpointStateValue) && checkpointStateValue is not null) @@ -103,46 +107,49 @@ public static IList LoadToolRequests(object data, LoadContext? { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'toolRequests' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "toolRequests" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'toolRequests' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(HostToolRequest.Load(itemDict, context)); + result.Add(HostToolRequest.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["requestId"] = kvp.Value }; - result.Add(HostToolRequest.Load(newDict, context)); + result.Add(HostToolRequest.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(HostToolRequest.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(HostToolRequest.Load(itemDict, context)); + result.Add(HostToolRequest.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/pipeline/TurnOptions.cs b/runtime/csharp/Prompty.Core/Model/pipeline/TurnOptions.cs index ae61154b0..999862383 100644 --- a/runtime/csharp/Prompty.Core/Model/pipeline/TurnOptions.cs +++ b/runtime/csharp/Prompty.Core/Model/pipeline/TurnOptions.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -81,6 +84,7 @@ public TurnOptions() /// The loaded TurnOptions instance. public static TurnOptions Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -123,7 +127,7 @@ public static TurnOptions Load(Dictionary data, LoadContext? co if (data.TryGetValue("compaction", out var compactionValue) && compactionValue is not null) { - instance.Compaction = CompactionConfig.Load(compactionValue.GetDictionary(CompactionConfig.ShorthandProperty), context); + instance.Compaction = CompactionConfig.Load(compactionValue.GetDictionary(CompactionConfig.ShorthandProperty), context!.At("compaction")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/streaming/StreamOptions.cs b/runtime/csharp/Prompty.Core/Model/streaming/StreamOptions.cs index 200c46e78..d60c66bb6 100644 --- a/runtime/csharp/Prompty.Core/Model/streaming/StreamOptions.cs +++ b/runtime/csharp/Prompty.Core/Model/streaming/StreamOptions.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -45,6 +48,7 @@ public StreamOptions() /// The loaded StreamOptions instance. public static StreamOptions Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); diff --git a/runtime/csharp/Prompty.Core/Model/template/FormatConfig.cs b/runtime/csharp/Prompty.Core/Model/template/FormatConfig.cs index ee231d0a4..5fed0faa4 100644 --- a/runtime/csharp/Prompty.Core/Model/template/FormatConfig.cs +++ b/runtime/csharp/Prompty.Core/Model/template/FormatConfig.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -39,7 +42,7 @@ public FormatConfig() /// /// Options for the template engine /// - public IDictionary? Options { get; set; } + public IDictionary? Options { get; set; } @@ -53,6 +56,7 @@ public FormatConfig() /// The loaded FormatConfig instance. public static FormatConfig Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -66,7 +70,7 @@ public static FormatConfig Load(Dictionary data, LoadContext? c if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("strict", out var strictValue) && strictValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/template/ParserConfig.cs b/runtime/csharp/Prompty.Core/Model/template/ParserConfig.cs index 5ffceb029..68f700fa1 100644 --- a/runtime/csharp/Prompty.Core/Model/template/ParserConfig.cs +++ b/runtime/csharp/Prompty.Core/Model/template/ParserConfig.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -34,7 +37,7 @@ public ParserConfig() /// /// Options for the parser /// - public IDictionary? Options { get; set; } + public IDictionary? Options { get; set; } @@ -48,6 +51,7 @@ public ParserConfig() /// The loaded ParserConfig instance. public static ParserConfig Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -61,7 +65,7 @@ public static ParserConfig Load(Dictionary data, LoadContext? c if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("options", out var optionsValue) && optionsValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/template/Template.cs b/runtime/csharp/Prompty.Core/Model/template/Template.cs index e35e379e9..0d924a68d 100644 --- a/runtime/csharp/Prompty.Core/Model/template/Template.cs +++ b/runtime/csharp/Prompty.Core/Model/template/Template.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -58,24 +61,35 @@ public Template() /// The loaded Template instance. public static Template Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("format", out var requiredFormatValue) || requiredFormatValue is null)) + { + throw new ArgumentException($"{context!.At("format").Path}: missing required field"); + } + + if ((!data.TryGetValue("parser", out var requiredParserValue) || requiredParserValue is null)) + { + throw new ArgumentException($"{context!.At("parser").Path}: missing required field"); + } + // Create new instance var instance = new Template(); if (data.TryGetValue("format", out var formatValue) && formatValue is not null) { - instance.Format = FormatConfig.Load(formatValue.GetDictionary(FormatConfig.ShorthandProperty), context); + instance.Format = FormatConfig.Load(formatValue.GetDictionary(FormatConfig.ShorthandProperty), context!.At("format")); } if (data.TryGetValue("parser", out var parserValue) && parserValue is not null) { - instance.Parser = ParserConfig.Load(parserValue.GetDictionary(ParserConfig.ShorthandProperty), context); + instance.Parser = ParserConfig.Load(parserValue.GetDictionary(ParserConfig.ShorthandProperty), context!.At("parser")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/tools/Binding.cs b/runtime/csharp/Prompty.Core/Model/tools/Binding.cs index 65e896f40..d1bba8776 100644 --- a/runtime/csharp/Prompty.Core/Model/tools/Binding.cs +++ b/runtime/csharp/Prompty.Core/Model/tools/Binding.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,6 +51,7 @@ public Binding() /// The loaded Binding instance. public static Binding Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -61,12 +65,12 @@ public static Binding Load(Dictionary data, LoadContext? contex if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("input", out var inputValue) && inputValue is not null) { - instance.Input = inputValue?.ToString()!; + instance.Input = inputValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/tools/CustomTool.cs b/runtime/csharp/Prompty.Core/Model/tools/CustomTool.cs index f6b0ff8f0..9c043a7be 100644 --- a/runtime/csharp/Prompty.Core/Model/tools/CustomTool.cs +++ b/runtime/csharp/Prompty.Core/Model/tools/CustomTool.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -47,7 +50,7 @@ public CustomTool() /// /// Configuration options for the server tool /// - public IDictionary Options { get; set; } = new Dictionary(); + public IDictionary Options { get; set; } = new Dictionary(); @@ -61,24 +64,45 @@ public CustomTool() /// The loaded CustomTool instance. public new static CustomTool Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if (!string.IsNullOrEmpty(data.GetValueOrDefault("kind")?.ToString()) && (!data.TryGetValue("connection", out var requiredConnectionValue) || requiredConnectionValue is null)) + { + throw new ArgumentException($"{context!.At("connection").Path}: missing required field"); + } + // Create new instance var instance = new CustomTool(); + if (data.TryGetValue("name", out var nameValue) && nameValue is not null) + { + instance.Name = nameValue.ToString()!; + } + if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; + } + + if (data.TryGetValue("description", out var descriptionValue) && descriptionValue is not null) + { + instance.Description = descriptionValue.ToString()!; + } + + if (data.TryGetValue("bindings", out var bindingsValue) && bindingsValue is not null) + { + instance.Bindings = LoadBindings(bindingsValue, context!.At("bindings")); } if (data.TryGetValue("connection", out var connectionValue) && connectionValue is not null) { - instance.Connection = Connection.Load(connectionValue.GetDictionary(Connection.ShorthandProperty), context); + instance.Connection = Connection.Load(connectionValue.GetDictionary(Connection.ShorthandProperty), context!.At("connection")); } if (data.TryGetValue("options", out var optionsValue) && optionsValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/tools/FunctionTool.cs b/runtime/csharp/Prompty.Core/Model/tools/FunctionTool.cs index 131f1bb43..06c9b560f 100644 --- a/runtime/csharp/Prompty.Core/Model/tools/FunctionTool.cs +++ b/runtime/csharp/Prompty.Core/Model/tools/FunctionTool.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public FunctionTool() /// The loaded FunctionTool instance. public new static FunctionTool Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -63,14 +67,29 @@ public FunctionTool() var instance = new FunctionTool(); + if (data.TryGetValue("name", out var nameValue) && nameValue is not null) + { + instance.Name = nameValue.ToString()!; + } + if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; + } + + if (data.TryGetValue("description", out var descriptionValue) && descriptionValue is not null) + { + instance.Description = descriptionValue.ToString()!; + } + + if (data.TryGetValue("bindings", out var bindingsValue) && bindingsValue is not null) + { + instance.Bindings = LoadBindings(bindingsValue, context!.At("bindings")); } if (data.TryGetValue("parameters", out var parametersValue) && parametersValue is not null) { - instance.Parameters = LoadParameters(parametersValue, context); + instance.Parameters = LoadParameters(parametersValue, context!.At("parameters")); } if (data.TryGetValue("strict", out var strictValue) && strictValue is not null) @@ -93,46 +112,65 @@ public static IList LoadParameters(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'parameters' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "parameters" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'parameters' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Property.Load(itemDict, context)); + result.Add(Property.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, - ["example"] = kvp.Value + ["default"] = kvp.Value }; - result.Add(Property.Load(newDict, context)); + if (kvp.Value is int or long or short or byte) + { + newDict["kind"] = "integer"; + } + else if (kvp.Value is double or float or decimal) + { + newDict["kind"] = "float"; + } + else if (kvp.Value is string) + { + newDict["kind"] = "string"; + } + else if (kvp.Value is bool) + { + newDict["kind"] = "boolean"; + } + result.Add(Property.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Property.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Property.Load(itemDict, context)); + result.Add(Property.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -186,36 +224,42 @@ public static object SaveParameters(IList items, SaveContext? context) context ??= new SaveContext(); + var serialized = items.Select(item => new Dictionary(item.Save(context))).ToList(); + foreach (var itemData in serialized) + { + if (itemData.TryGetValue("name", out var nameValue) && nameValue is string { Length: 0 }) itemData.Remove("name"); + } + if (context.CollectionFormat == "array") { - return items.Select(item => item.Save(context)).ToList(); + return serialized; + } + + var names = new HashSet(StringComparer.Ordinal); + foreach (var itemData in serialized) + { + if (!itemData.TryGetValue("name", out var nameValue) || nameValue is not string { Length: > 0 } name || !names.Add(name)) return serialized; } // Object format: use name as key var result = new Dictionary(); - foreach (var item in items) + for (var index = 0; index < items.Count; index++) { - var itemData = item.Save(context); - if (itemData.TryGetValue("name", out var nameValue) && nameValue is string name) - { - itemData.Remove("name"); + var item = items[index]; + var itemData = serialized[index]; + var name = (string)itemData["name"]!; + itemData.Remove("name"); - // Check if we can use shorthand - if (context.UseShorthand && Property.ShorthandProperty is string shorthandProp) + // Check if we can use shorthand + if (context.UseShorthand && Property.ShorthandProperty is string shorthandProp) + { + if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) { - if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) - { - result[name] = itemData[shorthandProp]; - continue; - } + result[name] = itemData[shorthandProp]; + continue; } - result[name] = itemData; - } - else - { - // No name, can't use object format for this item - throw new InvalidOperationException("Cannot save item in object format: missing 'name' property"); } + result[name] = itemData; } return result; diff --git a/runtime/csharp/Prompty.Core/Model/tools/McpApprovalMode.cs b/runtime/csharp/Prompty.Core/Model/tools/McpApprovalMode.cs index 90542fbc5..5e7bb3857 100644 --- a/runtime/csharp/Prompty.Core/Model/tools/McpApprovalMode.cs +++ b/runtime/csharp/Prompty.Core/Model/tools/McpApprovalMode.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -57,6 +60,7 @@ public McpApprovalMode() /// The loaded McpApprovalMode instance. public static McpApprovalMode Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -70,7 +74,7 @@ public static McpApprovalMode Load(Dictionary data, LoadContext if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = McpApprovalModeKindParser.Parse(kindValue?.ToString()!); + instance.Kind = McpApprovalModeKindParser.Parse(kindValue.ToString()!); } if (data.TryGetValue("alwaysRequireApprovalTools", out var alwaysRequireApprovalToolsValue) && alwaysRequireApprovalToolsValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/tools/McpApprovalModeKind.cs b/runtime/csharp/Prompty.Core/Model/tools/McpApprovalModeKind.cs index eecf82ba2..349049cda 100644 --- a/runtime/csharp/Prompty.Core/Model/tools/McpApprovalModeKind.cs +++ b/runtime/csharp/Prompty.Core/Model/tools/McpApprovalModeKind.cs @@ -1,6 +1,7 @@ // // // Code generated by Typra emitter; DO NOT EDIT. +#nullable enable using System; using System.Text.Json.Serialization; diff --git a/runtime/csharp/Prompty.Core/Model/tools/McpTool.cs b/runtime/csharp/Prompty.Core/Model/tools/McpTool.cs index b8e3cb6d1..16d9c74f8 100644 --- a/runtime/csharp/Prompty.Core/Model/tools/McpTool.cs +++ b/runtime/csharp/Prompty.Core/Model/tools/McpTool.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -49,7 +52,7 @@ public McpTool() /// /// The approval mode for the MCP tool /// - public McpApprovalMode ApprovalMode { get; set; } + public McpApprovalMode? ApprovalMode { get; set; } /// /// List of allowed operations or resources for the MCP tool @@ -68,39 +71,60 @@ public McpTool() /// The loaded McpTool instance. public new static McpTool Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("connection", out var requiredConnectionValue) || requiredConnectionValue is null)) + { + throw new ArgumentException($"{context!.At("connection").Path}: missing required field"); + } + // Create new instance var instance = new McpTool(); + if (data.TryGetValue("name", out var nameValue) && nameValue is not null) + { + instance.Name = nameValue.ToString()!; + } + if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; + } + + if (data.TryGetValue("description", out var descriptionValue) && descriptionValue is not null) + { + instance.Description = descriptionValue.ToString()!; + } + + if (data.TryGetValue("bindings", out var bindingsValue) && bindingsValue is not null) + { + instance.Bindings = LoadBindings(bindingsValue, context!.At("bindings")); } if (data.TryGetValue("connection", out var connectionValue) && connectionValue is not null) { - instance.Connection = Connection.Load(connectionValue.GetDictionary(Connection.ShorthandProperty), context); + instance.Connection = Connection.Load(connectionValue.GetDictionary(Connection.ShorthandProperty), context!.At("connection")); } if (data.TryGetValue("serverName", out var serverNameValue) && serverNameValue is not null) { - instance.ServerName = serverNameValue?.ToString()!; + instance.ServerName = serverNameValue.ToString()!; } if (data.TryGetValue("serverDescription", out var serverDescriptionValue) && serverDescriptionValue is not null) { - instance.ServerDescription = serverDescriptionValue?.ToString()!; + instance.ServerDescription = serverDescriptionValue.ToString()!; } if (data.TryGetValue("approvalMode", out var approvalModeValue) && approvalModeValue is not null) { - instance.ApprovalMode = McpApprovalMode.Load(approvalModeValue.GetDictionary(McpApprovalMode.ShorthandProperty), context); + instance.ApprovalMode = McpApprovalMode.Load(approvalModeValue.GetDictionary(McpApprovalMode.ShorthandProperty), context!.At("approvalMode")); } if (data.TryGetValue("allowedTools", out var allowedToolsValue) && allowedToolsValue is not null) @@ -153,7 +177,10 @@ public McpTool() } - result["approvalMode"] = obj.ApprovalMode?.Save(context); + if (obj.ApprovalMode is not null) + { + result["approvalMode"] = obj.ApprovalMode?.Save(context); + } if (obj.AllowedTools is not null) diff --git a/runtime/csharp/Prompty.Core/Model/tools/OpenApiTool.cs b/runtime/csharp/Prompty.Core/Model/tools/OpenApiTool.cs index 6cc425783..c88dde337 100644 --- a/runtime/csharp/Prompty.Core/Model/tools/OpenApiTool.cs +++ b/runtime/csharp/Prompty.Core/Model/tools/OpenApiTool.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -52,29 +55,50 @@ public OpenApiTool() /// The loaded OpenApiTool instance. public new static OpenApiTool Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("connection", out var requiredConnectionValue) || requiredConnectionValue is null)) + { + throw new ArgumentException($"{context!.At("connection").Path}: missing required field"); + } + // Create new instance var instance = new OpenApiTool(); + if (data.TryGetValue("name", out var nameValue) && nameValue is not null) + { + instance.Name = nameValue.ToString()!; + } + if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; + } + + if (data.TryGetValue("description", out var descriptionValue) && descriptionValue is not null) + { + instance.Description = descriptionValue.ToString()!; + } + + if (data.TryGetValue("bindings", out var bindingsValue) && bindingsValue is not null) + { + instance.Bindings = LoadBindings(bindingsValue, context!.At("bindings")); } if (data.TryGetValue("connection", out var connectionValue) && connectionValue is not null) { - instance.Connection = Connection.Load(connectionValue.GetDictionary(Connection.ShorthandProperty), context); + instance.Connection = Connection.Load(connectionValue.GetDictionary(Connection.ShorthandProperty), context!.At("connection")); } if (data.TryGetValue("specification", out var specificationValue) && specificationValue is not null) { - instance.Specification = specificationValue?.ToString()!; + instance.Specification = specificationValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/tools/PromptyTool.cs b/runtime/csharp/Prompty.Core/Model/tools/PromptyTool.cs index 02dec5933..924f457b6 100644 --- a/runtime/csharp/Prompty.Core/Model/tools/PromptyTool.cs +++ b/runtime/csharp/Prompty.Core/Model/tools/PromptyTool.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -57,6 +60,7 @@ public PromptyTool() /// The loaded PromptyTool instance. public new static PromptyTool Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -67,19 +71,34 @@ public PromptyTool() var instance = new PromptyTool(); + if (data.TryGetValue("name", out var nameValue) && nameValue is not null) + { + instance.Name = nameValue.ToString()!; + } + if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; + } + + if (data.TryGetValue("description", out var descriptionValue) && descriptionValue is not null) + { + instance.Description = descriptionValue.ToString()!; + } + + if (data.TryGetValue("bindings", out var bindingsValue) && bindingsValue is not null) + { + instance.Bindings = LoadBindings(bindingsValue, context!.At("bindings")); } if (data.TryGetValue("path", out var pathValue) && pathValue is not null) { - instance.Path = pathValue?.ToString()!; + instance.Path = pathValue.ToString()!; } if (data.TryGetValue("mode", out var modeValue) && modeValue is not null) { - instance.Mode = modeValue?.ToString()!; + instance.Mode = modeValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/tools/Tool.cs b/runtime/csharp/Prompty.Core/Model/tools/Tool.cs index f37e9ca0b..aa3fc2716 100644 --- a/runtime/csharp/Prompty.Core/Model/tools/Tool.cs +++ b/runtime/csharp/Prompty.Core/Model/tools/Tool.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -44,7 +47,7 @@ protected Tool() /// /// Tool argument bindings to input properties /// - public IList? Bindings { get; set; } + public IList? Bindings { get; set; } = []; @@ -58,6 +61,7 @@ protected Tool() /// The loaded Tool instance. public static Tool Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -70,22 +74,22 @@ public static Tool Load(Dictionary data, LoadContext? context = if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("kind", out var kindValue) && kindValue is not null) { - instance.Kind = kindValue?.ToString()!; + instance.Kind = kindValue.ToString()!; } if (data.TryGetValue("description", out var descriptionValue) && descriptionValue is not null) { - instance.Description = descriptionValue?.ToString()!; + instance.Description = descriptionValue.ToString()!; } if (data.TryGetValue("bindings", out var bindingsValue) && bindingsValue is not null) { - instance.Bindings = LoadBindings(bindingsValue, context); + instance.Bindings = LoadBindings(bindingsValue, context!.At("bindings")); } if (context is not null) @@ -103,46 +107,49 @@ public static IList LoadBindings(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'bindings' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "bindings" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'bindings' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Binding.Load(itemDict, context)); + result.Add(Binding.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["input"] = kvp.Value }; - result.Add(Binding.Load(newDict, context)); + result.Add(Binding.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Binding.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Binding.Load(itemDict, context)); + result.Add(Binding.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -157,7 +164,7 @@ private static Tool LoadKind(Dictionary data, LoadContext? cont { if (data.TryGetValue("kind", out var discriminatorValue) && discriminatorValue is not null) { - var discriminator = discriminatorValue.ToString()?.ToLowerInvariant(); + var discriminator = discriminatorValue.ToString(); return discriminator switch { "function" => FunctionTool.Load(data, context), @@ -229,36 +236,42 @@ public static object SaveBindings(IList items, SaveContext? context) context ??= new SaveContext(); + var serialized = items.Select(item => new Dictionary(item.Save(context))).ToList(); + foreach (var itemData in serialized) + { + if (itemData.TryGetValue("name", out var nameValue) && nameValue is string { Length: 0 }) itemData.Remove("name"); + } + if (context.CollectionFormat == "array") { - return items.Select(item => item.Save(context)).ToList(); + return serialized; + } + + var names = new HashSet(StringComparer.Ordinal); + foreach (var itemData in serialized) + { + if (!itemData.TryGetValue("name", out var nameValue) || nameValue is not string { Length: > 0 } name || !names.Add(name)) return serialized; } // Object format: use name as key var result = new Dictionary(); - foreach (var item in items) + for (var index = 0; index < items.Count; index++) { - var itemData = item.Save(context); - if (itemData.TryGetValue("name", out var nameValue) && nameValue is string name) - { - itemData.Remove("name"); + var item = items[index]; + var itemData = serialized[index]; + var name = (string)itemData["name"]!; + itemData.Remove("name"); - // Check if we can use shorthand - if (context.UseShorthand && Binding.ShorthandProperty is string shorthandProp) + // Check if we can use shorthand + if (context.UseShorthand && Binding.ShorthandProperty is string shorthandProp) + { + if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) { - if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) - { - result[name] = itemData[shorthandProp]; - continue; - } + result[name] = itemData[shorthandProp]; + continue; } - result[name] = itemData; - } - else - { - // No name, can't use object format for this item - throw new InvalidOperationException("Cannot save item in object format: missing 'name' property"); } + result[name] = itemData; } return result; diff --git a/runtime/csharp/Prompty.Core/Model/tools/ToolContext.cs b/runtime/csharp/Prompty.Core/Model/tools/ToolContext.cs index e94becdd2..09d78a702 100644 --- a/runtime/csharp/Prompty.Core/Model/tools/ToolContext.cs +++ b/runtime/csharp/Prompty.Core/Model/tools/ToolContext.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -38,7 +41,7 @@ public ToolContext() /// /// Optional metadata for tool-specific context (e.g., user session info) /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } @@ -52,6 +55,7 @@ public ToolContext() /// The loaded ToolContext instance. public static ToolContext Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -64,7 +68,7 @@ public static ToolContext Load(Dictionary data, LoadContext? co if (data.TryGetValue("messages", out var messagesValue) && messagesValue is not null) { - instance.Messages = LoadMessages(messagesValue, context); + instance.Messages = LoadMessages(messagesValue, context!.At("messages")); } if (data.TryGetValue("metadata", out var metadataValue) && metadataValue is not null) @@ -87,46 +91,49 @@ public static IList LoadMessages(object data, LoadContext? context) { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'messages' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "messages" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'messages' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["role"] = kvp.Value }; - result.Add(Message.Load(newDict, context)); + result.Add(Message.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(Message.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(Message.Load(itemDict, context)); + result.Add(Message.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } diff --git a/runtime/csharp/Prompty.Core/Model/tools/ToolDispatchResult.cs b/runtime/csharp/Prompty.Core/Model/tools/ToolDispatchResult.cs index 6508ef0ce..5d6962db2 100644 --- a/runtime/csharp/Prompty.Core/Model/tools/ToolDispatchResult.cs +++ b/runtime/csharp/Prompty.Core/Model/tools/ToolDispatchResult.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -57,29 +60,35 @@ public ToolDispatchResult() /// The loaded ToolDispatchResult instance. public static ToolDispatchResult Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("result", out var requiredResultValue) || requiredResultValue is null)) + { + throw new ArgumentException($"{context!.At("result").Path}: missing required field"); + } + // Create new instance var instance = new ToolDispatchResult(); if (data.TryGetValue("toolCallId", out var toolCallIdValue) && toolCallIdValue is not null) { - instance.ToolCallId = toolCallIdValue?.ToString()!; + instance.ToolCallId = toolCallIdValue.ToString()!; } if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("result", out var resultValue) && resultValue is not null) { - instance.Result = ToolResult.Load(resultValue.GetDictionary(ToolResult.ShorthandProperty), context); + instance.Result = ToolResult.Load(resultValue.GetDictionary(ToolResult.ShorthandProperty), context!.At("result")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/tracing/TraceFile.cs b/runtime/csharp/Prompty.Core/Model/tracing/TraceFile.cs index 841aef88c..c014eabd2 100644 --- a/runtime/csharp/Prompty.Core/Model/tracing/TraceFile.cs +++ b/runtime/csharp/Prompty.Core/Model/tracing/TraceFile.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,29 +56,35 @@ public TraceFile() /// The loaded TraceFile instance. public static TraceFile Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("trace", out var requiredTraceValue) || requiredTraceValue is null)) + { + throw new ArgumentException($"{context!.At("trace").Path}: missing required field"); + } + // Create new instance var instance = new TraceFile(); if (data.TryGetValue("runtime", out var runtimeValue) && runtimeValue is not null) { - instance.Runtime = runtimeValue?.ToString()!; + instance.Runtime = runtimeValue.ToString()!; } if (data.TryGetValue("version", out var versionValue) && versionValue is not null) { - instance.Version = versionValue?.ToString()!; + instance.Version = versionValue.ToString()!; } if (data.TryGetValue("trace", out var traceValue) && traceValue is not null) { - instance.Trace = TraceSpan.Load(traceValue.GetDictionary(TraceSpan.ShorthandProperty), context); + instance.Trace = TraceSpan.Load(traceValue.GetDictionary(TraceSpan.ShorthandProperty), context!.At("trace")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/tracing/TraceSpan.cs b/runtime/csharp/Prompty.Core/Model/tracing/TraceSpan.cs index 5136543f0..f1d22c914 100644 --- a/runtime/csharp/Prompty.Core/Model/tracing/TraceSpan.cs +++ b/runtime/csharp/Prompty.Core/Model/tracing/TraceSpan.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,7 +51,7 @@ public TraceSpan() /// /// Serialized input parameters (redacted per §3.4) /// - public IDictionary? Inputs { get; set; } + public IDictionary? Inputs { get; set; } /// /// Serialized return value or error information (redacted per §3.4) @@ -68,7 +71,7 @@ public TraceSpan() /// /// Additional span attributes (e.g., OpenTelemetry GenAI attributes) /// - public IDictionary? Attributes { get; set; } + public IDictionary? Attributes { get; set; } /// /// Nested child spans forming the execution tree (recursive; each element is a TraceSpan) @@ -87,29 +90,35 @@ public TraceSpan() /// The loaded TraceSpan instance. public static TraceSpan Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("__time", out var required_TimeValue) || required_TimeValue is null)) + { + throw new ArgumentException($"{context!.At("__time").Path}: missing required field"); + } + // Create new instance var instance = new TraceSpan(); if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("__time", out var __timeValue) && __timeValue is not null) { - instance._Time = TraceTime.Load(__timeValue.GetDictionary(TraceTime.ShorthandProperty), context); + instance._Time = TraceTime.Load(__timeValue.GetDictionary(TraceTime.ShorthandProperty), context!.At("__time")); } if (data.TryGetValue("signature", out var signatureValue) && signatureValue is not null) { - instance.Signature = signatureValue?.ToString()!; + instance.Signature = signatureValue.ToString()!; } if (data.TryGetValue("inputs", out var inputsValue) && inputsValue is not null) @@ -124,12 +133,12 @@ public static TraceSpan Load(Dictionary data, LoadContext? cont if (data.TryGetValue("error", out var errorValue) && errorValue is not null) { - instance.Error = errorValue?.ToString()!; + instance.Error = errorValue.ToString()!; } if (data.TryGetValue("__usage", out var __usageValue) && __usageValue is not null) { - instance._Usage = TokenUsage.Load(__usageValue.GetDictionary(TokenUsage.ShorthandProperty), context); + instance._Usage = TokenUsage.Load(__usageValue.GetDictionary(TokenUsage.ShorthandProperty), context!.At("__usage")); } if (data.TryGetValue("attributes", out var attributesValue) && attributesValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/tracing/TraceTime.cs b/runtime/csharp/Prompty.Core/Model/tracing/TraceTime.cs index ff77d50c1..f4cd615b5 100644 --- a/runtime/csharp/Prompty.Core/Model/tracing/TraceTime.cs +++ b/runtime/csharp/Prompty.Core/Model/tracing/TraceTime.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public TraceTime() /// The loaded TraceTime instance. public static TraceTime Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,12 +69,12 @@ public static TraceTime Load(Dictionary data, LoadContext? cont if (data.TryGetValue("start", out var startValue) && startValue is not null) { - instance.Start = startValue?.ToString()!; + instance.Start = startValue.ToString()!; } if (data.TryGetValue("end", out var endValue) && endValue is not null) { - instance.End = endValue?.ToString()!; + instance.End = endValue.ToString()!; } if (data.TryGetValue("duration", out var durationValue) && durationValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/wire/AnthropicImageBlock.cs b/runtime/csharp/Prompty.Core/Model/wire/AnthropicImageBlock.cs index 6b4f62941..3a44f1434 100644 --- a/runtime/csharp/Prompty.Core/Model/wire/AnthropicImageBlock.cs +++ b/runtime/csharp/Prompty.Core/Model/wire/AnthropicImageBlock.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -50,24 +53,30 @@ public AnthropicImageBlock() /// The loaded AnthropicImageBlock instance. public static AnthropicImageBlock Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("source", out var requiredSourceValue) || requiredSourceValue is null)) + { + throw new ArgumentException($"{context!.At("source").Path}: missing required field"); + } + // Create new instance var instance = new AnthropicImageBlock(); if (data.TryGetValue("type", out var typeValue) && typeValue is not null) { - instance.Type = typeValue?.ToString()!; + instance.Type = typeValue.ToString()!; } if (data.TryGetValue("source", out var sourceValue) && sourceValue is not null) { - instance.Source = AnthropicImageSource.Load(sourceValue.GetDictionary(AnthropicImageSource.ShorthandProperty), context); + instance.Source = AnthropicImageSource.Load(sourceValue.GetDictionary(AnthropicImageSource.ShorthandProperty), context!.At("source")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/wire/AnthropicImageSource.cs b/runtime/csharp/Prompty.Core/Model/wire/AnthropicImageSource.cs index f535d7562..f74387afa 100644 --- a/runtime/csharp/Prompty.Core/Model/wire/AnthropicImageSource.cs +++ b/runtime/csharp/Prompty.Core/Model/wire/AnthropicImageSource.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public AnthropicImageSource() /// The loaded AnthropicImageSource instance. public static AnthropicImageSource Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,17 +69,17 @@ public static AnthropicImageSource Load(Dictionary data, LoadCo if (data.TryGetValue("type", out var typeValue) && typeValue is not null) { - instance.Type = typeValue?.ToString()!; + instance.Type = typeValue.ToString()!; } if (data.TryGetValue("media_type", out var media_typeValue) && media_typeValue is not null) { - instance.MediaType = media_typeValue?.ToString()!; + instance.MediaType = media_typeValue.ToString()!; } if (data.TryGetValue("data", out var dataValue) && dataValue is not null) { - instance.Data = dataValue?.ToString()!; + instance.Data = dataValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/wire/AnthropicMessagesRequest.cs b/runtime/csharp/Prompty.Core/Model/wire/AnthropicMessagesRequest.cs index cbf44f5ba..68d8023ad 100644 --- a/runtime/csharp/Prompty.Core/Model/wire/AnthropicMessagesRequest.cs +++ b/runtime/csharp/Prompty.Core/Model/wire/AnthropicMessagesRequest.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -83,6 +86,7 @@ public AnthropicMessagesRequest() /// The loaded AnthropicMessagesRequest instance. public static AnthropicMessagesRequest Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -95,12 +99,12 @@ public static AnthropicMessagesRequest Load(Dictionary data, Lo if (data.TryGetValue("model", out var modelValue) && modelValue is not null) { - instance.Model = modelValue?.ToString()!; + instance.Model = modelValue.ToString()!; } if (data.TryGetValue("messages", out var messagesValue) && messagesValue is not null) { - instance.Messages = LoadMessages(messagesValue, context); + instance.Messages = LoadMessages(messagesValue, context!.At("messages")); } if (data.TryGetValue("max_tokens", out var max_tokensValue) && max_tokensValue is not null) @@ -110,7 +114,7 @@ public static AnthropicMessagesRequest Load(Dictionary data, Lo if (data.TryGetValue("system", out var systemValue) && systemValue is not null) { - instance.System = systemValue?.ToString()!; + instance.System = systemValue.ToString()!; } if (data.TryGetValue("temperature", out var temperatureValue) && temperatureValue is not null) @@ -135,7 +139,7 @@ public static AnthropicMessagesRequest Load(Dictionary data, Lo if (data.TryGetValue("tools", out var toolsValue) && toolsValue is not null) { - instance.Tools = LoadTools(toolsValue, context); + instance.Tools = LoadTools(toolsValue, context!.At("tools")); } if (context is not null) @@ -153,46 +157,49 @@ public static IList LoadMessages(object data, LoadContext? { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'messages' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "messages" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'messages' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(AnthropicWireMessage.Load(itemDict, context)); + result.Add(AnthropicWireMessage.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["role"] = kvp.Value }; - result.Add(AnthropicWireMessage.Load(newDict, context)); + result.Add(AnthropicWireMessage.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(AnthropicWireMessage.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(AnthropicWireMessage.Load(itemDict, context)); + result.Add(AnthropicWireMessage.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -207,46 +214,49 @@ public static IList LoadTools(object data, LoadContext? { var result = new List(); - if (data is Dictionary dict) + if (data is System.Collections.IDictionary) { + var dict = data.GetDictionary(); // Convert named dictionary to list foreach (var kvp in dict) { if (kvp.Value is IEnumerable) { throw new ArgumentException( - $"Invalid 'tools' format: key '{kvp.Key}' has an array value. " + + $"{(string.IsNullOrEmpty(context?.Path) ? "tools" : context.Path)}.{kvp.Key}: invalid named collection entry category array. " + $"'tools' must be a flat list of objects or a name-keyed dict — " + "not a nested {" + kvp.Key + ": [...]} structure."); } - var itemDict = kvp.Value.GetDictionary(); + var itemDict = new Dictionary(kvp.Value.GetDictionary()); if (itemDict.Count > 0) { // Value is an object, add name to it itemDict["name"] = kvp.Key; - result.Add(AnthropicToolDefinition.Load(itemDict, context)); + result.Add(AnthropicToolDefinition.Load(itemDict, context?.At(kvp.Key))); } else { - // Value is a scalar, use it as the primary property + // Value is a scalar, infer the entry shape from its runtime type var newDict = new Dictionary { ["name"] = kvp.Key, ["description"] = kvp.Value }; - result.Add(AnthropicToolDefinition.Load(newDict, context)); + result.Add(AnthropicToolDefinition.Load(newDict, context?.At(kvp.Key))); } } } else if (data is IEnumerable list) { + var itemIndex = 0; foreach (var item in list) { var itemDict = item.GetDictionary(AnthropicToolDefinition.ShorthandProperty); if (itemDict.Count > 0) { - result.Add(AnthropicToolDefinition.Load(itemDict, context)); + result.Add(AnthropicToolDefinition.Load(itemDict, context?.AtIndex(itemIndex))); } + itemIndex++; } } @@ -349,39 +359,8 @@ public static object SaveTools(IList items, SaveContext { context ??= new SaveContext(); - - if (context.CollectionFormat == "array") - { - return items.Select(item => item.Save(context)).ToList(); - } - - // Object format: use name as key - var result = new Dictionary(); - foreach (var item in items) - { - var itemData = item.Save(context); - if (itemData.TryGetValue("name", out var nameValue) && nameValue is string name) - { - itemData.Remove("name"); - - // Check if we can use shorthand - if (context.UseShorthand && AnthropicToolDefinition.ShorthandProperty is string shorthandProp) - { - if (itemData.Count == 1 && itemData.ContainsKey(shorthandProp)) - { - result[name] = itemData[shorthandProp]; - continue; - } - } - result[name] = itemData; - } - else - { - // No name, can't use object format for this item - throw new InvalidOperationException("Cannot save item in object format: missing 'name' property"); - } - } - return result; + // This collection type does not have a 'name' property, only array format is supported + return items.Select(item => item.Save(context)).ToList(); } diff --git a/runtime/csharp/Prompty.Core/Model/wire/AnthropicMessagesResponse.cs b/runtime/csharp/Prompty.Core/Model/wire/AnthropicMessagesResponse.cs index d12290063..5a56d362d 100644 --- a/runtime/csharp/Prompty.Core/Model/wire/AnthropicMessagesResponse.cs +++ b/runtime/csharp/Prompty.Core/Model/wire/AnthropicMessagesResponse.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -73,29 +76,35 @@ public AnthropicMessagesResponse() /// The loaded AnthropicMessagesResponse instance. public static AnthropicMessagesResponse Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); } + if ((!data.TryGetValue("usage", out var requiredUsageValue) || requiredUsageValue is null)) + { + throw new ArgumentException($"{context!.At("usage").Path}: missing required field"); + } + // Create new instance var instance = new AnthropicMessagesResponse(); if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("type", out var typeValue) && typeValue is not null) { - instance.Type = typeValue?.ToString()!; + instance.Type = typeValue.ToString()!; } if (data.TryGetValue("role", out var roleValue) && roleValue is not null) { - instance.Role = roleValue?.ToString()!; + instance.Role = roleValue.ToString()!; } if (data.TryGetValue("content", out var contentValue) && contentValue is not null) @@ -105,17 +114,17 @@ public static AnthropicMessagesResponse Load(Dictionary data, L if (data.TryGetValue("model", out var modelValue) && modelValue is not null) { - instance.Model = modelValue?.ToString()!; + instance.Model = modelValue.ToString()!; } if (data.TryGetValue("stop_reason", out var stop_reasonValue) && stop_reasonValue is not null) { - instance.StopReason = stop_reasonValue?.ToString()!; + instance.StopReason = stop_reasonValue.ToString()!; } if (data.TryGetValue("usage", out var usageValue) && usageValue is not null) { - instance.Usage = AnthropicUsage.Load(usageValue.GetDictionary(AnthropicUsage.ShorthandProperty), context); + instance.Usage = AnthropicUsage.Load(usageValue.GetDictionary(AnthropicUsage.ShorthandProperty), context!.At("usage")); } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/wire/AnthropicTextBlock.cs b/runtime/csharp/Prompty.Core/Model/wire/AnthropicTextBlock.cs index 3c9b4b140..fda74073a 100644 --- a/runtime/csharp/Prompty.Core/Model/wire/AnthropicTextBlock.cs +++ b/runtime/csharp/Prompty.Core/Model/wire/AnthropicTextBlock.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,6 +51,7 @@ public AnthropicTextBlock() /// The loaded AnthropicTextBlock instance. public static AnthropicTextBlock Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -60,12 +64,12 @@ public static AnthropicTextBlock Load(Dictionary data, LoadCont if (data.TryGetValue("type", out var typeValue) && typeValue is not null) { - instance.Type = typeValue?.ToString()!; + instance.Type = typeValue.ToString()!; } if (data.TryGetValue("text", out var textValue) && textValue is not null) { - instance.Text = textValue?.ToString()!; + instance.Text = textValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/wire/AnthropicToolDefinition.cs b/runtime/csharp/Prompty.Core/Model/wire/AnthropicToolDefinition.cs index e747210cd..ebb42f56c 100644 --- a/runtime/csharp/Prompty.Core/Model/wire/AnthropicToolDefinition.cs +++ b/runtime/csharp/Prompty.Core/Model/wire/AnthropicToolDefinition.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -43,7 +46,7 @@ public AnthropicToolDefinition() /// /// JSON Schema describing the tool's input parameters /// - public IDictionary InputSchema { get; set; } = new Dictionary(); + public IDictionary InputSchema { get; set; } = new Dictionary(); @@ -57,6 +60,7 @@ public AnthropicToolDefinition() /// The loaded AnthropicToolDefinition instance. public static AnthropicToolDefinition Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -69,12 +73,12 @@ public static AnthropicToolDefinition Load(Dictionary data, Loa if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("description", out var descriptionValue) && descriptionValue is not null) { - instance.Description = descriptionValue?.ToString()!; + instance.Description = descriptionValue.ToString()!; } if (data.TryGetValue("input_schema", out var input_schemaValue) && input_schemaValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/wire/AnthropicToolResultBlock.cs b/runtime/csharp/Prompty.Core/Model/wire/AnthropicToolResultBlock.cs index 0e9f86a27..4b1b2709c 100644 --- a/runtime/csharp/Prompty.Core/Model/wire/AnthropicToolResultBlock.cs +++ b/runtime/csharp/Prompty.Core/Model/wire/AnthropicToolResultBlock.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -53,6 +56,7 @@ public AnthropicToolResultBlock() /// The loaded AnthropicToolResultBlock instance. public static AnthropicToolResultBlock Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -65,17 +69,17 @@ public static AnthropicToolResultBlock Load(Dictionary data, Lo if (data.TryGetValue("type", out var typeValue) && typeValue is not null) { - instance.Type = typeValue?.ToString()!; + instance.Type = typeValue.ToString()!; } if (data.TryGetValue("tool_use_id", out var tool_use_idValue) && tool_use_idValue is not null) { - instance.ToolUseId = tool_use_idValue?.ToString()!; + instance.ToolUseId = tool_use_idValue.ToString()!; } if (data.TryGetValue("content", out var contentValue) && contentValue is not null) { - instance.Content = contentValue?.ToString()!; + instance.Content = contentValue.ToString()!; } if (context is not null) diff --git a/runtime/csharp/Prompty.Core/Model/wire/AnthropicToolUseBlock.cs b/runtime/csharp/Prompty.Core/Model/wire/AnthropicToolUseBlock.cs index b32d3b106..cd2045ffd 100644 --- a/runtime/csharp/Prompty.Core/Model/wire/AnthropicToolUseBlock.cs +++ b/runtime/csharp/Prompty.Core/Model/wire/AnthropicToolUseBlock.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -46,7 +49,7 @@ public AnthropicToolUseBlock() /// /// The JSON arguments for the tool call /// - public IDictionary Input { get; set; } = new Dictionary(); + public IDictionary Input { get; set; } = new Dictionary(); @@ -60,6 +63,7 @@ public AnthropicToolUseBlock() /// The loaded AnthropicToolUseBlock instance. public static AnthropicToolUseBlock Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -72,17 +76,17 @@ public static AnthropicToolUseBlock Load(Dictionary data, LoadC if (data.TryGetValue("type", out var typeValue) && typeValue is not null) { - instance.Type = typeValue?.ToString()!; + instance.Type = typeValue.ToString()!; } if (data.TryGetValue("id", out var idValue) && idValue is not null) { - instance.Id = idValue?.ToString()!; + instance.Id = idValue.ToString()!; } if (data.TryGetValue("name", out var nameValue) && nameValue is not null) { - instance.Name = nameValue?.ToString()!; + instance.Name = nameValue.ToString()!; } if (data.TryGetValue("input", out var inputValue) && inputValue is not null) diff --git a/runtime/csharp/Prompty.Core/Model/wire/AnthropicUsage.cs b/runtime/csharp/Prompty.Core/Model/wire/AnthropicUsage.cs index ae1c30877..6a64a7e71 100644 --- a/runtime/csharp/Prompty.Core/Model/wire/AnthropicUsage.cs +++ b/runtime/csharp/Prompty.Core/Model/wire/AnthropicUsage.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -48,6 +51,7 @@ public AnthropicUsage() /// The loaded AnthropicUsage instance. public static AnthropicUsage Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); diff --git a/runtime/csharp/Prompty.Core/Model/wire/AnthropicWireMessage.cs b/runtime/csharp/Prompty.Core/Model/wire/AnthropicWireMessage.cs index edc93a2b2..097871edf 100644 --- a/runtime/csharp/Prompty.Core/Model/wire/AnthropicWireMessage.cs +++ b/runtime/csharp/Prompty.Core/Model/wire/AnthropicWireMessage.cs @@ -1,6 +1,9 @@ // // Copyright (c) Microsoft. All rights reserved. +#nullable enable + using System.Text.Json; +using System.Threading; using YamlDotNet.Serialization; #pragma warning disable IDE0130 @@ -52,6 +55,7 @@ public AnthropicWireMessage() /// The loaded AnthropicWireMessage instance. public static AnthropicWireMessage Load(Dictionary data, LoadContext? context = null) { + context ??= new LoadContext(); if (context is not null) { data = context.ProcessInput(data); @@ -64,7 +68,7 @@ public static AnthropicWireMessage Load(Dictionary data, LoadCo if (data.TryGetValue("role", out var roleValue) && roleValue is not null) { - instance.Role = roleValue?.ToString()!; + instance.Role = roleValue.ToString()!; } if (data.TryGetValue("content", out var contentValue) && contentValue is not null) diff --git a/runtime/csharp/Prompty.Core/Pipeline.cs b/runtime/csharp/Prompty.Core/Pipeline.cs index a476ca3a7..f3ce51bc7 100644 --- a/runtime/csharp/Prompty.Core/Pipeline.cs +++ b/runtime/csharp/Prompty.Core/Pipeline.cs @@ -104,14 +104,17 @@ public static async Task> ParseAsync(Prompty agent, string rendere /// /// Execute an LLM call with the given messages. /// - public static async Task ExecuteAsync(Prompty agent, List messages) + public static async Task ExecuteAsync( + Prompty agent, + List messages, + CancellationToken cancellationToken = default) { return await Trace.TraceAsync("Prompty.Core.Pipeline.ExecuteAsync", async (emit) => { emit("inputs", new Dictionary { ["agent"] = agent.Name, ["message_count"] = messages.Count }); var provider = agent.Model?.Provider ?? "openai"; var executor = InvokerRegistry.GetExecutor(provider); - return await executor.ExecuteAsync(agent, messages); + return await executor.ExecuteAsync(agent, messages, cancellationToken); }); } @@ -280,7 +283,7 @@ public static async Task TurnAsync( object response; try { - response = await ExecuteAsync(agent, messages); + response = await ExecuteAsync(agent, messages, cancellationToken); } catch (Exception ex) { @@ -842,7 +845,7 @@ private static async Task InvokeWithRetryAsync( { try { - return await ExecuteAsync(agent, messages); + return await ExecuteAsync(agent, messages, cancellationToken); } catch (Exception ex) when (ex is not OperationCanceledException) { diff --git a/runtime/csharp/Prompty.Core/SchemaHelpers.cs b/runtime/csharp/Prompty.Core/SchemaHelpers.cs index 86bf3b84a..29d4c6dac 100644 --- a/runtime/csharp/Prompty.Core/SchemaHelpers.cs +++ b/runtime/csharp/Prompty.Core/SchemaHelpers.cs @@ -16,7 +16,8 @@ public static class SchemaHelpers IList? properties, bool strict = false, ISet? excludedPropertyNames = null, - bool supportsOneOf = true) + bool supportsOneOf = true, + bool fillEmptyStructures = true) { var result = new Dictionary { ["type"] = "object" }; @@ -35,7 +36,8 @@ public static class SchemaHelpers prop, optional: strict && prop.Required != true, strict: strict, - supportsOneOf: supportsOneOf); + supportsOneOf: supportsOneOf, + fillEmptyStructures: fillEmptyStructures); if (prop.Required == true) required.Add(prop.Name); @@ -63,7 +65,8 @@ public static class SchemaHelpers Property prop, bool optional = false, bool strict = false, - bool supportsOneOf = true) + bool supportsOneOf = true, + bool fillEmptyStructures = true) { var schema = new Dictionary(); if (IsKnownJsonSchemaKind(prop.Kind)) @@ -95,26 +98,45 @@ public static class SchemaHelpers } schema["oneOf"] = unionProperty.OneOf! - .Select(branch => PropertyToJsonSchema(branch, strict: strict, supportsOneOf: supportsOneOf)) + .Select(branch => PropertyToJsonSchema( + branch, + strict: strict, + supportsOneOf: supportsOneOf, + fillEmptyStructures: fillEmptyStructures)) .ToList(); } else { schema["anyOf"] = unionProperty.AnyOf! - .Select(branch => PropertyToJsonSchema(branch, strict: strict, supportsOneOf: supportsOneOf)) + .Select(branch => PropertyToJsonSchema( + branch, + strict: strict, + supportsOneOf: supportsOneOf, + fillEmptyStructures: fillEmptyStructures)) .ToList(); } } - // Array items — recurse into item schema + // Array items — OpenAI emits a bare {"type": "array"} when items is + // unspecified; Anthropic requires an items schema, so it keeps a filler. if (prop is ArrayProperty arrayProp) { - schema["items"] = arrayProp.Items is not null - ? PropertyToJsonSchema(arrayProp.Items, strict: strict, supportsOneOf: supportsOneOf) - : new Dictionary { ["type"] = "string" }; + if (arrayProp.Items is not null) + { + schema["items"] = PropertyToJsonSchema( + arrayProp.Items, + strict: strict, + supportsOneOf: supportsOneOf, + fillEmptyStructures: fillEmptyStructures); + } + else if (fillEmptyStructures) + { + schema["items"] = new Dictionary { ["type"] = "string" }; + } } - // Object properties — recurse into nested properties + // Object properties — OpenAI emits a bare {"type": "object"} when + // properties is empty or absent; Anthropic keeps an empty container. if (prop is ObjectProperty objectProp) { if (objectProp.Properties is not null && objectProp.Properties.Count > 0) @@ -128,19 +150,21 @@ public static class SchemaHelpers p, optional: strict && p.Required != true, strict: strict, - supportsOneOf: supportsOneOf); + supportsOneOf: supportsOneOf, + fillEmptyStructures: fillEmptyStructures); if (strict || p.Required == true) nestedRequired.Add(p.Name); } schema["properties"] = nested; schema["required"] = nestedRequired; + schema["additionalProperties"] = false; } - else + else if (fillEmptyStructures) { schema["properties"] = new Dictionary(); schema["required"] = new List(); + schema["additionalProperties"] = false; } - schema["additionalProperties"] = false; } if (prop.Nullable == true || optional) diff --git a/runtime/csharp/Prompty.Core/TurnRunner.cs b/runtime/csharp/Prompty.Core/TurnRunner.cs index 928caaac5..b861612c8 100644 --- a/runtime/csharp/Prompty.Core/TurnRunner.cs +++ b/runtime/csharp/Prompty.Core/TurnRunner.cs @@ -189,7 +189,7 @@ private async Task ResolveAndExecuteToolAsync(string turnId, int if (!decision.Approved) { - return new HostToolResult + var denied = new HostToolResult { RequestId = toolRequest.RequestId, ToolCallId = toolRequest.ToolCallId, @@ -198,6 +198,8 @@ private async Task ResolveAndExecuteToolAsync(string turnId, int ErrorKind = "permission_denied", Result = new Dictionary { ["message"] = decision.Reason ?? "Permission denied" } }; + RecordTurn(TurnEventType.ToolResult, turnId, iteration, denied.Save()); + return denied; } RecordTurn(TurnEventType.ToolExecutionStart, turnId, iteration, toolRequest.Save()); diff --git a/runtime/csharp/Prompty.OpenAI.Tests/AgentLoopTests.cs b/runtime/csharp/Prompty.OpenAI.Tests/AgentLoopTests.cs index 563759e14..cb54135a1 100644 --- a/runtime/csharp/Prompty.OpenAI.Tests/AgentLoopTests.cs +++ b/runtime/csharp/Prompty.OpenAI.Tests/AgentLoopTests.cs @@ -269,7 +269,10 @@ public Task> ParseAsync(Core.Prompty agent, string rendered, Dicti /// Executor that returns a fixed response. private class MockExecutor(object response) : IExecutor { - public Task ExecuteAsync(Core.Prompty agent, List messages) + public Task ExecuteAsync( + Core.Prompty agent, + List messages, + CancellationToken cancellationToken = default) => Task.FromResult(response); public List FormatToolMessages(object rawResponse, List toolCalls, List toolResults, string? textContent = null) @@ -296,7 +299,10 @@ private class SequenceExecutor(List responses) : IExecutor private int _index; public int CallCount => _index; - public Task ExecuteAsync(Core.Prompty agent, List messages) + public Task ExecuteAsync( + Core.Prompty agent, + List messages, + CancellationToken cancellationToken = default) { if (_index >= responses.Count) throw new InvalidOperationException("SequenceExecutor ran out of responses."); @@ -310,7 +316,10 @@ public List FormatToolMessages(object rawResponse, List toolC /// Executor that always returns a tool call — for testing max iterations. private class InfiniteToolCallExecutor : IExecutor { - public Task ExecuteAsync(Core.Prompty agent, List messages) + public Task ExecuteAsync( + Core.Prompty agent, + List messages, + CancellationToken cancellationToken = default) { return Task.FromResult(new ToolCallResult { diff --git a/runtime/csharp/Prompty.OpenAI.Tests/OpenAIExecutorTests.cs b/runtime/csharp/Prompty.OpenAI.Tests/OpenAIExecutorTests.cs index 3e006632c..c340e0837 100644 --- a/runtime/csharp/Prompty.OpenAI.Tests/OpenAIExecutorTests.cs +++ b/runtime/csharp/Prompty.OpenAI.Tests/OpenAIExecutorTests.cs @@ -10,6 +10,17 @@ namespace Prompty.OpenAI.Tests; /// public class OpenAIExecutorTests { + [Fact] + public async Task ExecuteAsync_Cancelled_ThrowsBeforeConnectionValidation() + { + var executor = new OpenAI.OpenAIExecutor(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + () => executor.ExecuteAsync(new Core.Prompty(), [], cancellation.Token)); + } + [Fact] public async Task ExecuteAsync_MissingApiKey_ThrowsInvalidOperationException() { @@ -146,4 +157,3 @@ public void FormatToolMessages_CreatesIndividualToolMessages() Assert.Equal("call_2", messages[2].Metadata["tool_call_id"]); } } - diff --git a/runtime/csharp/Prompty.OpenAI.Tests/SpecVectorAgentTests.cs b/runtime/csharp/Prompty.OpenAI.Tests/SpecVectorAgentTests.cs index a4a0af53d..26a4bc31a 100644 --- a/runtime/csharp/Prompty.OpenAI.Tests/SpecVectorAgentTests.cs +++ b/runtime/csharp/Prompty.OpenAI.Tests/SpecVectorAgentTests.cs @@ -715,7 +715,10 @@ public Task ProcessAsync(Core.Prompty agent, object response) private class LambdaExecutor(Func, object> fn) : IExecutor { - public Task ExecuteAsync(Core.Prompty agent, List messages) + public Task ExecuteAsync( + Core.Prompty agent, + List messages, + CancellationToken cancellationToken = default) => Task.FromResult(fn(messages)); public List FormatToolMessages(object rawResponse, List toolCalls, List toolResults, string? textContent = null) diff --git a/runtime/csharp/Prompty.OpenAI.Tests/SpecVectorWireTests.cs b/runtime/csharp/Prompty.OpenAI.Tests/SpecVectorWireTests.cs index aa12b8637..9ff3f7e9c 100644 --- a/runtime/csharp/Prompty.OpenAI.Tests/SpecVectorWireTests.cs +++ b/runtime/csharp/Prompty.OpenAI.Tests/SpecVectorWireTests.cs @@ -131,6 +131,8 @@ public void OpenAI_Chat_WireFormat(string name, JsonElement input, JsonElement e $"[{name}] Expected 'response_format' in serialized options but not found"); AssertJsonSubset(expectedRF, actualRF, $"[{name}] response_format"); } + + AssertNoSchemaNamedOptions(optionsJson, expectedBody, name); } public static IEnumerable OpenAIChatVectors() @@ -326,6 +328,37 @@ public void OpenAI_Responses_WireFormat(string name, JsonElement input, JsonElem $"[{name}] Expected 'text' in serialized options"); AssertJsonSubset(expectedText, actualText, $"[{name}] text"); } + + AssertNoSchemaNamedOptions(actualJson, expectedBody, name); + } + + /// + /// Asserts that no ModelOptions field appears under its *schema* name in the + /// actual body unless the spec declares it. A provider that has no wire mapping + /// for an option must omit it entirely rather than fall back to the schema field + /// name (typra #84) — see the `responses_unmapped_options` wire vector. + /// + /// This is deliberately narrower than a full extra-key comparison: these bodies + /// are produced by the Azure SDK serializer, which legitimately adds keys of its + /// own. Schema field names are never among them. + /// + private static void AssertNoSchemaNamedOptions(JsonElement actual, JsonElement expectedBody, string name) + { + string[] schemaNames = + [ + "frequencyPenalty", "maxOutputTokens", "presencePenalty", "seed", + "temperature", "topK", "topP", "stopSequences", + "allowMultipleToolCalls", "additionalProperties", + ]; + + var leaked = schemaNames + .Where(s => !expectedBody.TryGetProperty(s, out _)) + .Where(s => actual.TryGetProperty(s, out _)) + .OrderBy(s => s, StringComparer.Ordinal) + .ToList(); + + Assert.True(leaked.Count == 0, + $"[{name}]: ModelOptions fields emitted under their schema names instead of being omitted: {string.Join(", ", leaked)}. Actual: {actual.GetRawText()}"); } public static IEnumerable OpenAIResponsesVectors() @@ -388,6 +421,18 @@ public void Anthropic_Chat_WireFormat(string name, JsonElement input, JsonElemen AssertJsonSubset(prop.Value, actualProp, $"[{name}].{prop.Name}"); } } + + // ...and no key in the actual body that the spec does not declare. Without + // this, a provider emitting an option it has no wire mapping for (typra #84) + // passes silently — see the `anthropic_unmapped_options` wire vector. + var expectedKeys = expectedBody.EnumerateObject().Select(p => p.Name).ToHashSet(); + var extraKeys = bodyJson.EnumerateObject() + .Select(p => p.Name) + .Where(k => !expectedKeys.Contains(k)) + .OrderBy(k => k, StringComparer.Ordinal) + .ToList(); + Assert.True(extraKeys.Count == 0, + $"[{name}]: unexpected keys in actual body (spec says absent): {string.Join(", ", extraKeys)}. Actual: {bodyJson.GetRawText()}"); } public static IEnumerable AnthropicChatVectors() diff --git a/runtime/csharp/Prompty.OpenAI/OpenAIExecutor.cs b/runtime/csharp/Prompty.OpenAI/OpenAIExecutor.cs index 335b635bd..ae711ac0e 100644 --- a/runtime/csharp/Prompty.OpenAI/OpenAIExecutor.cs +++ b/runtime/csharp/Prompty.OpenAI/OpenAIExecutor.cs @@ -20,8 +20,12 @@ namespace Prompty.OpenAI; /// public class OpenAIExecutor : IExecutor { - public async Task ExecuteAsync(Core.Prompty agent, List messages) + public async Task ExecuteAsync( + Core.Prompty agent, + List messages, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var apiType = agent.Model?.ApiType ?? "chat"; var model = agent.Model?.Id ?? "gpt-4"; var client = CreateClient(agent); @@ -29,12 +33,12 @@ public async Task ExecuteAsync(Core.Prompty agent, List message return apiType switch { - "chat" when streaming => ExecuteChatStreamAsync(client, model, agent, messages), - "chat" => await ExecuteChatAsync(client, model, agent, messages), - "responses" when streaming => ExecuteResponsesStreamAsync(client, model, agent, messages), - "responses" => await ExecuteResponsesAsync(client, model, agent, messages), - "embedding" => await ExecuteEmbeddingAsync(client, model, messages), - "image" => await ExecuteImageAsync(client, model, messages), + "chat" when streaming => ExecuteChatStreamAsync(client, model, agent, messages, cancellationToken), + "chat" => await ExecuteChatAsync(client, model, agent, messages, cancellationToken), + "responses" when streaming => ExecuteResponsesStreamAsync(client, model, agent, messages, cancellationToken), + "responses" => await ExecuteResponsesAsync(client, model, agent, messages, cancellationToken), + "embedding" => await ExecuteEmbeddingAsync(client, model, messages, cancellationToken), + "image" => await ExecuteImageAsync(client, model, messages, cancellationToken), _ => throw new InvalidOperationException($"Unsupported API type: {apiType}"), }; } @@ -83,18 +87,26 @@ protected virtual OpenAIClient CreateClient(Core.Prompty agent) } private static async Task ExecuteChatAsync( - OpenAIClient client, string model, Core.Prompty agent, List messages) + OpenAIClient client, + string model, + Core.Prompty agent, + List messages, + CancellationToken cancellationToken) { var chatClient = client.GetChatClient(model); var chatMessages = messages.Select(WireFormat.MessageToWire).ToList(); var options = WireFormat.BuildOptions(agent); - var result = await chatClient.CompleteChatAsync(chatMessages, options); + var result = await chatClient.CompleteChatAsync(chatMessages, options, cancellationToken); return result.Value; } private static PromptyStream ExecuteChatStreamAsync( - OpenAIClient client, string model, Core.Prompty agent, List messages) + OpenAIClient client, + string model, + Core.Prompty agent, + List messages, + CancellationToken cancellationToken) { var chatClient = client.GetChatClient(model); var chatMessages = messages.Select(WireFormat.MessageToWire).ToList(); @@ -109,7 +121,7 @@ async IAsyncEnumerable StreamChunks([EnumeratorCancellation] Cancellatio } } - return new PromptyStream(StreamChunks()); + return new PromptyStream(StreamChunks(cancellationToken)); } // ----------------------------------------------------------------------- @@ -117,16 +129,24 @@ async IAsyncEnumerable StreamChunks([EnumeratorCancellation] Cancellatio // ----------------------------------------------------------------------- private static async Task ExecuteResponsesAsync( - OpenAIClient client, string model, Core.Prompty agent, List messages) + OpenAIClient client, + string model, + Core.Prompty agent, + List messages, + CancellationToken cancellationToken) { var responsesClient = client.GetResponsesClient(); var options = WireFormat.BuildResponsesOptions(model, agent, messages); - var result = await responsesClient.CreateResponseAsync(options); + var result = await responsesClient.CreateResponseAsync(options, cancellationToken); return result.Value; } private static PromptyStream ExecuteResponsesStreamAsync( - OpenAIClient client, string model, Core.Prompty agent, List messages) + OpenAIClient client, + string model, + Core.Prompty agent, + List messages, + CancellationToken cancellationToken) { var responsesClient = client.GetResponsesClient(); var options = WireFormat.BuildResponsesOptions(model, agent, messages); @@ -141,7 +161,7 @@ async IAsyncEnumerable StreamChunks([EnumeratorCancellation] Cancellatio } } - return new PromptyStream(StreamChunks()); + return new PromptyStream(StreamChunks(cancellationToken)); } // ----------------------------------------------------------------------- @@ -149,20 +169,26 @@ async IAsyncEnumerable StreamChunks([EnumeratorCancellation] Cancellatio // ----------------------------------------------------------------------- private static async Task ExecuteEmbeddingAsync( - OpenAIClient client, string model, List messages) + OpenAIClient client, + string model, + List messages, + CancellationToken cancellationToken) { var embeddingClient = client.GetEmbeddingClient(model); var inputs = messages.Select(m => m.Text).ToList(); - var result = await embeddingClient.GenerateEmbeddingsAsync(inputs); + var result = await embeddingClient.GenerateEmbeddingsAsync(inputs, cancellationToken: cancellationToken); return result.Value; } private static async Task ExecuteImageAsync( - OpenAIClient client, string model, List messages) + OpenAIClient client, + string model, + List messages, + CancellationToken cancellationToken) { var imageClient = client.GetImageClient(model); var prompt = messages.LastOrDefault()?.Text ?? ""; - var result = await imageClient.GenerateImageAsync(prompt); + var result = await imageClient.GenerateImageAsync(prompt, cancellationToken: cancellationToken); return result.Value; } diff --git a/runtime/csharp/Prompty.OpenAI/WireFormat.cs b/runtime/csharp/Prompty.OpenAI/WireFormat.cs index 2da3e4909..64b158826 100644 --- a/runtime/csharp/Prompty.OpenAI/WireFormat.cs +++ b/runtime/csharp/Prompty.OpenAI/WireFormat.cs @@ -102,7 +102,8 @@ private static AssistantChatMessage BuildAssistantMessage(Message msg) ft.Parameters, ft.Strict == true, boundNames, - supportsOneOf: false); + supportsOneOf: false, + fillEmptyStructures: false); var chatTool = ChatTool.CreateFunctionTool( ft.Name ?? "", ft.Description, @@ -123,7 +124,7 @@ private static AssistantChatMessage BuildAssistantMessage(Message msg) if (agent.Outputs is null || agent.Outputs.Count == 0) return null; - var schema = SchemaHelpers.PropertiesToJsonSchema(agent.Outputs, strict: true, supportsOneOf: false); + var schema = SchemaHelpers.PropertiesToJsonSchema(agent.Outputs, strict: true, supportsOneOf: false, fillEmptyStructures: false); return ChatResponseFormat.CreateJsonSchemaFormat( "structured_output", @@ -241,7 +242,7 @@ public static CreateResponseOptions BuildResponsesOptions(string model, Core.Pro // Structured output → text.format (json_schema) if (agent.Outputs is not null && agent.Outputs.Count > 0) { - var schema = SchemaHelpers.PropertiesToJsonSchema(agent.Outputs, strict: true, supportsOneOf: false); + var schema = SchemaHelpers.PropertiesToJsonSchema(agent.Outputs, strict: true, supportsOneOf: false, fillEmptyStructures: false); options.TextOptions ??= new ResponseTextOptions(); options.TextOptions.TextFormat = ResponseTextFormat.CreateJsonSchemaFormat( "structured_output", @@ -300,7 +301,8 @@ public static CreateResponseOptions BuildResponsesOptions(string model, Core.Pro ft.Parameters, ft.Strict == true, boundNames, - supportsOneOf: false); + supportsOneOf: false, + fillEmptyStructures: false); var responseTool = ResponseTool.CreateFunctionTool( ft.Name ?? "", BinaryData.FromString(JsonSerializer.Serialize(parameters)), diff --git a/runtime/go/prompty/model/ai_resource_info.go b/runtime/go/prompty/model/ai_resource_info.go index cf081dbdf..aa1961570 100644 --- a/runtime/go/prompty/model/ai_resource_info.go +++ b/runtime/go/prompty/model/ai_resource_info.go @@ -23,6 +23,9 @@ type AiResourceInfo struct { // LoadAiResourceInfo creates a AiResourceInfo from a map[string]interface{} func LoadAiResourceInfo(data interface{}, ctx *LoadContext) (AiResourceInfo, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := AiResourceInfo{} // Load from map diff --git a/runtime/go/prompty/model/anthropic_image_block.go b/runtime/go/prompty/model/anthropic_image_block.go index 12ebc2a7e..6c99b9ba6 100644 --- a/runtime/go/prompty/model/anthropic_image_block.go +++ b/runtime/go/prompty/model/anthropic_image_block.go @@ -6,6 +6,7 @@ package prompty import ( "encoding/json" + "fmt" "gopkg.in/yaml.v3" ) @@ -20,16 +21,22 @@ type AnthropicImageBlock struct { // LoadAnthropicImageBlock creates a AnthropicImageBlock from a map[string]interface{} func LoadAnthropicImageBlock(data interface{}, ctx *LoadContext) (AnthropicImageBlock, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := AnthropicImageBlock{} // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["source"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("source").Path) + } if val, ok := m["type"]; ok && val != nil { result.Type = string(val.(string)) } if val, ok := m["source"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadAnthropicImageSource(m, ctx) + loaded, err := LoadAnthropicImageSource(m, ctx.At("source")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/anthropic_image_source.go b/runtime/go/prompty/model/anthropic_image_source.go index b3afae856..2759d4887 100644 --- a/runtime/go/prompty/model/anthropic_image_source.go +++ b/runtime/go/prompty/model/anthropic_image_source.go @@ -20,6 +20,9 @@ type AnthropicImageSource struct { // LoadAnthropicImageSource creates a AnthropicImageSource from a map[string]interface{} func LoadAnthropicImageSource(data interface{}, ctx *LoadContext) (AnthropicImageSource, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := AnthropicImageSource{} // Load from map diff --git a/runtime/go/prompty/model/anthropic_messages_request.go b/runtime/go/prompty/model/anthropic_messages_request.go index f689e66dc..bd2401bc5 100644 --- a/runtime/go/prompty/model/anthropic_messages_request.go +++ b/runtime/go/prompty/model/anthropic_messages_request.go @@ -26,6 +26,9 @@ type AnthropicMessagesRequest struct { // LoadAnthropicMessagesRequest creates a AnthropicMessagesRequest from a map[string]interface{} func LoadAnthropicMessagesRequest(data interface{}, ctx *LoadContext) (AnthropicMessagesRequest, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := AnthropicMessagesRequest{} // Load from map @@ -38,7 +41,7 @@ func LoadAnthropicMessagesRequest(data interface{}, ctx *LoadContext) (Anthropic result.Messages = make([]AnthropicWireMessage, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadAnthropicWireMessage(item, ctx) + loaded, err := LoadAnthropicWireMessage(item, ctx.At("messages").AtIndex(i)) if err != nil { return result, err } @@ -127,7 +130,7 @@ func LoadAnthropicMessagesRequest(data interface{}, ctx *LoadContext) (Anthropic result.Tools = make([]AnthropicToolDefinition, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadAnthropicToolDefinition(item, ctx) + loaded, err := LoadAnthropicToolDefinition(item, ctx.At("tools").AtIndex(i)) if err != nil { return result, err } @@ -165,7 +168,9 @@ func (obj AnthropicMessagesRequest) Save(ctx *SaveContext) map[string]interface{ if obj.TopK != nil { result["top_k"] = *obj.TopK } - result["stop_sequences"] = obj.StopSequences + if obj.StopSequences != nil { + result["stop_sequences"] = obj.StopSequences + } if obj.Tools != nil { arr := make([]interface{}, len(obj.Tools)) for i, item := range obj.Tools { diff --git a/runtime/go/prompty/model/anthropic_messages_request_test.go b/runtime/go/prompty/model/anthropic_messages_request_test.go index b53c142b8..8677b4876 100644 --- a/runtime/go/prompty/model/anthropic_messages_request_test.go +++ b/runtime/go/prompty/model/anthropic_messages_request_test.go @@ -5,6 +5,7 @@ package prompty_test import ( "encoding/json" + "reflect" "testing" "gopkg.in/yaml.v3" @@ -24,6 +25,12 @@ func TestAnthropicMessagesRequestLoadJSON(t *testing.T) { "top_k": 40, "stop_sequences": [ "\n\nHuman:" + ], + "messages": [ + { + "role": "user", + "content": [] + } ] } ` @@ -55,6 +62,10 @@ func TestAnthropicMessagesRequestLoadJSON(t *testing.T) { if instance.TopK == nil || *instance.TopK != 40 { t.Errorf(`Expected TopK to be 40, got %v`, instance.TopK) } + if len(instance.Messages) != 1 { + t.Fatalf("Expected Messages length to be 1, got %d", len(instance.Messages)) + } + assertAnthropicMessagesRequestStringField(t, instance.Messages[0], "Role", "user", "Messages[0].Role") if len(instance.StopSequences) != 1 { t.Fatalf("Expected StopSequences length to be 1, got %d", len(instance.StopSequences)) } @@ -73,7 +84,13 @@ temperature: 0.7 top_p: 0.9 top_k: 40 stop_sequences: - - "\n\nHuman:" + - |- + + + Human: +messages: + - role: user + content: [] ` var data map[string]interface{} @@ -104,6 +121,10 @@ stop_sequences: if instance.TopK == nil || *instance.TopK != 40 { t.Errorf(`Expected TopK to be 40, got %v`, instance.TopK) } + if len(instance.Messages) != 1 { + t.Fatalf("Expected Messages length to be 1, got %d", len(instance.Messages)) + } + assertAnthropicMessagesRequestStringField(t, instance.Messages[0], "Role", "user", "Messages[0].Role") if len(instance.StopSequences) != 1 { t.Fatalf("Expected StopSequences length to be 1, got %d", len(instance.StopSequences)) } @@ -124,6 +145,12 @@ func TestAnthropicMessagesRequestFromJSON(t *testing.T) { "top_k": 40, "stop_sequences": [ "\n\nHuman:" + ], + "messages": [ + { + "role": "user", + "content": [] + } ] } ` @@ -150,6 +177,10 @@ func TestAnthropicMessagesRequestFromJSON(t *testing.T) { if instance.TopK == nil || *instance.TopK != 40 { t.Errorf(`Expected TopK to be 40, got %v`, instance.TopK) } + if len(instance.Messages) != 1 { + t.Fatalf("Expected Messages length to be 1, got %d", len(instance.Messages)) + } + assertAnthropicMessagesRequestStringField(t, instance.Messages[0], "Role", "user", "Messages[0].Role") if len(instance.StopSequences) != 1 { t.Fatalf("Expected StopSequences length to be 1, got %d", len(instance.StopSequences)) } @@ -168,7 +199,13 @@ temperature: 0.7 top_p: 0.9 top_k: 40 stop_sequences: - - "\n\nHuman:" + - |- + + + Human: +messages: + - role: user + content: [] ` @@ -194,6 +231,10 @@ stop_sequences: if instance.TopK == nil || *instance.TopK != 40 { t.Errorf(`Expected TopK to be 40, got %v`, instance.TopK) } + if len(instance.Messages) != 1 { + t.Fatalf("Expected Messages length to be 1, got %d", len(instance.Messages)) + } + assertAnthropicMessagesRequestStringField(t, instance.Messages[0], "Role", "user", "Messages[0].Role") if len(instance.StopSequences) != 1 { t.Fatalf("Expected StopSequences length to be 1, got %d", len(instance.StopSequences)) } @@ -214,6 +255,12 @@ func TestAnthropicMessagesRequestRoundtrip(t *testing.T) { "top_k": 40, "stop_sequences": [ "\n\nHuman:" + ], + "messages": [ + { + "role": "user", + "content": [] + } ] } ` @@ -252,6 +299,10 @@ func TestAnthropicMessagesRequestRoundtrip(t *testing.T) { if reloaded.TopK == nil || *reloaded.TopK != 40 { t.Errorf(`Expected TopK to be 40, got %v`, reloaded.TopK) } + if len(reloaded.Messages) != 1 { + t.Fatalf("Expected Messages length to be 1, got %d", len(reloaded.Messages)) + } + assertAnthropicMessagesRequestStringField(t, reloaded.Messages[0], "Role", "user", "Messages[0].Role") if len(reloaded.StopSequences) != 1 { t.Fatalf("Expected StopSequences length to be 1, got %d", len(reloaded.StopSequences)) } @@ -272,6 +323,12 @@ func TestAnthropicMessagesRequestToJSON(t *testing.T) { "top_k": 40, "stop_sequences": [ "\n\nHuman:" + ], + "messages": [ + { + "role": "user", + "content": [] + } ] } ` @@ -317,6 +374,10 @@ func TestAnthropicMessagesRequestToJSON(t *testing.T) { if reloaded.TopK == nil || *reloaded.TopK != 40 { t.Errorf(`Expected TopK to be 40, got %v`, reloaded.TopK) } + if len(reloaded.Messages) != 1 { + t.Fatalf("Expected Messages length to be 1, got %d", len(reloaded.Messages)) + } + assertAnthropicMessagesRequestStringField(t, reloaded.Messages[0], "Role", "user", "Messages[0].Role") if len(reloaded.StopSequences) != 1 { t.Fatalf("Expected StopSequences length to be 1, got %d", len(reloaded.StopSequences)) } @@ -337,6 +398,12 @@ func TestAnthropicMessagesRequestToYAML(t *testing.T) { "top_k": 40, "stop_sequences": [ "\n\nHuman:" + ], + "messages": [ + { + "role": "user", + "content": [] + } ] } ` @@ -382,6 +449,10 @@ func TestAnthropicMessagesRequestToYAML(t *testing.T) { if reloaded.TopK == nil || *reloaded.TopK != 40 { t.Errorf(`Expected TopK to be 40, got %v`, reloaded.TopK) } + if len(reloaded.Messages) != 1 { + t.Fatalf("Expected Messages length to be 1, got %d", len(reloaded.Messages)) + } + assertAnthropicMessagesRequestStringField(t, reloaded.Messages[0], "Role", "user", "Messages[0].Role") if len(reloaded.StopSequences) != 1 { t.Fatalf("Expected StopSequences length to be 1, got %d", len(reloaded.StopSequences)) } @@ -396,3 +467,39 @@ func TestAnthropicMessagesRequestFromJSONInvalid(t *testing.T) { t.Fatalf("Expected malformed JSON to fail") } } + +func assertAnthropicMessagesRequestStringField(t *testing.T, value interface{}, fieldName string, expected string, displayName string) { + t.Helper() + field := reflect.ValueOf(value) + if field.Kind() == reflect.Pointer { + if field.IsNil() { + t.Fatalf("Expected %s to be populated", displayName) + } + field = field.Elem() + } + if field.Kind() != reflect.Struct { + t.Fatalf("Expected %s receiver to be a struct, got %T", displayName, value) + } + member := field.FieldByName(fieldName) + if !member.IsValid() { + t.Fatalf("Expected %s to have field %s, got %T", displayName, fieldName, value) + } + if member.Kind() == reflect.Pointer { + if member.IsNil() { + t.Fatalf("Expected %s to be populated", displayName) + } + member = member.Elem() + } + if member.Kind() == reflect.Interface { + if member.IsNil() { + t.Fatalf("Expected %s to be populated", displayName) + } + member = member.Elem() + } + if member.Kind() != reflect.String { + t.Fatalf("Expected %s to be a string field, got %s", displayName, member.Kind()) + } + if got := member.String(); got != expected { + t.Errorf("Expected %s to be %q, got %q", displayName, expected, got) + } +} diff --git a/runtime/go/prompty/model/anthropic_messages_response.go b/runtime/go/prompty/model/anthropic_messages_response.go index 653f453ad..63f03d3f3 100644 --- a/runtime/go/prompty/model/anthropic_messages_response.go +++ b/runtime/go/prompty/model/anthropic_messages_response.go @@ -6,6 +6,7 @@ package prompty import ( "encoding/json" + "fmt" "gopkg.in/yaml.v3" ) @@ -24,10 +25,16 @@ type AnthropicMessagesResponse struct { // LoadAnthropicMessagesResponse creates a AnthropicMessagesResponse from a map[string]interface{} func LoadAnthropicMessagesResponse(data interface{}, ctx *LoadContext) (AnthropicMessagesResponse, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := AnthropicMessagesResponse{} // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["usage"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("usage").Path) + } if val, ok := m["id"]; ok && val != nil { result.Id = string(val.(string)) } @@ -51,7 +58,7 @@ func LoadAnthropicMessagesResponse(data interface{}, ctx *LoadContext) (Anthropi } if val, ok := m["usage"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadAnthropicUsage(m, ctx) + loaded, err := LoadAnthropicUsage(m, ctx.At("usage")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/anthropic_messages_response_test.go b/runtime/go/prompty/model/anthropic_messages_response_test.go index 8be3d5cf6..8e266a1db 100644 --- a/runtime/go/prompty/model/anthropic_messages_response_test.go +++ b/runtime/go/prompty/model/anthropic_messages_response_test.go @@ -18,7 +18,11 @@ func TestAnthropicMessagesResponseLoadJSON(t *testing.T) { { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn" + "stop_reason": "end_turn", + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } } ` var data map[string]interface{} @@ -48,6 +52,9 @@ func TestAnthropicMessagesResponseLoadYAML(t *testing.T) { id: msg_01XFDUDYJgAACzvnptvVoYEL model: claude-sonnet-4-20250514 stop_reason: end_turn +usage: + input_tokens: 150 + output_tokens: 42 ` var data map[string]interface{} @@ -77,7 +84,11 @@ func TestAnthropicMessagesResponseFromJSON(t *testing.T) { { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn" + "stop_reason": "end_turn", + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } } ` @@ -102,6 +113,9 @@ func TestAnthropicMessagesResponseFromYAML(t *testing.T) { id: msg_01XFDUDYJgAACzvnptvVoYEL model: claude-sonnet-4-20250514 stop_reason: end_turn +usage: + input_tokens: 150 + output_tokens: 42 ` @@ -126,7 +140,11 @@ func TestAnthropicMessagesResponseRoundtrip(t *testing.T) { { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn" + "stop_reason": "end_turn", + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } } ` var data map[string]interface{} @@ -163,7 +181,11 @@ func TestAnthropicMessagesResponseToJSON(t *testing.T) { { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn" + "stop_reason": "end_turn", + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } } ` var data map[string]interface{} @@ -207,7 +229,11 @@ func TestAnthropicMessagesResponseToYAML(t *testing.T) { { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn" + "stop_reason": "end_turn", + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } } ` var data map[string]interface{} diff --git a/runtime/go/prompty/model/anthropic_text_block.go b/runtime/go/prompty/model/anthropic_text_block.go index f2d782e23..a09a7ab68 100644 --- a/runtime/go/prompty/model/anthropic_text_block.go +++ b/runtime/go/prompty/model/anthropic_text_block.go @@ -19,6 +19,9 @@ type AnthropicTextBlock struct { // LoadAnthropicTextBlock creates a AnthropicTextBlock from a map[string]interface{} func LoadAnthropicTextBlock(data interface{}, ctx *LoadContext) (AnthropicTextBlock, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := AnthropicTextBlock{} // Load from map diff --git a/runtime/go/prompty/model/anthropic_tool_definition.go b/runtime/go/prompty/model/anthropic_tool_definition.go index eebdc3e10..4d71f8e99 100644 --- a/runtime/go/prompty/model/anthropic_tool_definition.go +++ b/runtime/go/prompty/model/anthropic_tool_definition.go @@ -22,6 +22,9 @@ type AnthropicToolDefinition struct { // LoadAnthropicToolDefinition creates a AnthropicToolDefinition from a map[string]interface{} func LoadAnthropicToolDefinition(data interface{}, ctx *LoadContext) (AnthropicToolDefinition, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := AnthropicToolDefinition{} // Load from map diff --git a/runtime/go/prompty/model/anthropic_tool_result_block.go b/runtime/go/prompty/model/anthropic_tool_result_block.go index bb7ecfceb..ffe18c963 100644 --- a/runtime/go/prompty/model/anthropic_tool_result_block.go +++ b/runtime/go/prompty/model/anthropic_tool_result_block.go @@ -20,6 +20,9 @@ type AnthropicToolResultBlock struct { // LoadAnthropicToolResultBlock creates a AnthropicToolResultBlock from a map[string]interface{} func LoadAnthropicToolResultBlock(data interface{}, ctx *LoadContext) (AnthropicToolResultBlock, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := AnthropicToolResultBlock{} // Load from map diff --git a/runtime/go/prompty/model/anthropic_tool_use_block.go b/runtime/go/prompty/model/anthropic_tool_use_block.go index 35e8fdc63..7fba5e438 100644 --- a/runtime/go/prompty/model/anthropic_tool_use_block.go +++ b/runtime/go/prompty/model/anthropic_tool_use_block.go @@ -22,6 +22,9 @@ type AnthropicToolUseBlock struct { // LoadAnthropicToolUseBlock creates a AnthropicToolUseBlock from a map[string]interface{} func LoadAnthropicToolUseBlock(data interface{}, ctx *LoadContext) (AnthropicToolUseBlock, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := AnthropicToolUseBlock{} // Load from map diff --git a/runtime/go/prompty/model/anthropic_usage.go b/runtime/go/prompty/model/anthropic_usage.go index 1a95ac2e9..579addb7a 100644 --- a/runtime/go/prompty/model/anthropic_usage.go +++ b/runtime/go/prompty/model/anthropic_usage.go @@ -19,6 +19,9 @@ type AnthropicUsage struct { // LoadAnthropicUsage creates a AnthropicUsage from a map[string]interface{} func LoadAnthropicUsage(data interface{}, ctx *LoadContext) (AnthropicUsage, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := AnthropicUsage{} // Load from map diff --git a/runtime/go/prompty/model/anthropic_wire_message.go b/runtime/go/prompty/model/anthropic_wire_message.go index 60c23b37d..1df68c81e 100644 --- a/runtime/go/prompty/model/anthropic_wire_message.go +++ b/runtime/go/prompty/model/anthropic_wire_message.go @@ -21,6 +21,9 @@ type AnthropicWireMessage struct { // LoadAnthropicWireMessage creates a AnthropicWireMessage from a map[string]interface{} func LoadAnthropicWireMessage(data interface{}, ctx *LoadContext) (AnthropicWireMessage, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := AnthropicWireMessage{} // Load from map diff --git a/runtime/go/prompty/model/authorization_code_flow.go b/runtime/go/prompty/model/authorization_code_flow.go index 85c37fc76..eef6dfa53 100644 --- a/runtime/go/prompty/model/authorization_code_flow.go +++ b/runtime/go/prompty/model/authorization_code_flow.go @@ -19,6 +19,9 @@ type AuthorizationCodeFlow struct { // LoadAuthorizationCodeFlow creates a AuthorizationCodeFlow from a map[string]interface{} func LoadAuthorizationCodeFlow(data interface{}, ctx *LoadContext) (AuthorizationCodeFlow, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := AuthorizationCodeFlow{} // Load from map diff --git a/runtime/go/prompty/model/binding.go b/runtime/go/prompty/model/binding.go index 40ffaee3f..92ba424a2 100644 --- a/runtime/go/prompty/model/binding.go +++ b/runtime/go/prompty/model/binding.go @@ -19,6 +19,9 @@ type Binding struct { // LoadBinding creates a Binding from a map[string]interface{} func LoadBinding(data interface{}, ctx *LoadContext) (Binding, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := Binding{} // Handle alternate scalar representations diff --git a/runtime/go/prompty/model/checkpoint.go b/runtime/go/prompty/model/checkpoint.go index 3012ffc39..55ca1f1f0 100644 --- a/runtime/go/prompty/model/checkpoint.go +++ b/runtime/go/prompty/model/checkpoint.go @@ -28,6 +28,9 @@ type Checkpoint struct { // LoadCheckpoint creates a Checkpoint from a map[string]interface{} func LoadCheckpoint(data interface{}, ctx *LoadContext) (Checkpoint, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := Checkpoint{} // Load from map @@ -85,7 +88,7 @@ func LoadCheckpoint(data interface{}, ctx *LoadContext) (Checkpoint, error) { } if val, ok := m["redaction"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadRedactionMetadata(m, ctx) + loaded, err := LoadRedactionMetadata(m, ctx.At("redaction")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/compaction_complete_payload.go b/runtime/go/prompty/model/compaction_complete_payload.go index b2a955e31..39522cf8e 100644 --- a/runtime/go/prompty/model/compaction_complete_payload.go +++ b/runtime/go/prompty/model/compaction_complete_payload.go @@ -20,6 +20,9 @@ type CompactionCompletePayload struct { // LoadCompactionCompletePayload creates a CompactionCompletePayload from a map[string]interface{} func LoadCompactionCompletePayload(data interface{}, ctx *LoadContext) (CompactionCompletePayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := CompactionCompletePayload{} // Load from map diff --git a/runtime/go/prompty/model/compaction_config.go b/runtime/go/prompty/model/compaction_config.go index 4a6997604..ee1406f01 100644 --- a/runtime/go/prompty/model/compaction_config.go +++ b/runtime/go/prompty/model/compaction_config.go @@ -17,11 +17,14 @@ import ( type CompactionConfig struct { Strategy *string `json:"strategy,omitempty" yaml:"strategy,omitempty"` Budget *int32 `json:"budget,omitempty" yaml:"budget,omitempty"` - Options map[string]interface{} `json:"options,omitempty" yaml:"options,omitempty"` + Options map[string]interface{} `json:"options" yaml:"options"` } // LoadCompactionConfig creates a CompactionConfig from a map[string]interface{} func LoadCompactionConfig(data interface{}, ctx *LoadContext) (CompactionConfig, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := CompactionConfig{} // Load from map diff --git a/runtime/go/prompty/model/compaction_failed_payload.go b/runtime/go/prompty/model/compaction_failed_payload.go index 4fabe9c0b..af0424731 100644 --- a/runtime/go/prompty/model/compaction_failed_payload.go +++ b/runtime/go/prompty/model/compaction_failed_payload.go @@ -18,6 +18,9 @@ type CompactionFailedPayload struct { // LoadCompactionFailedPayload creates a CompactionFailedPayload from a map[string]interface{} func LoadCompactionFailedPayload(data interface{}, ctx *LoadContext) (CompactionFailedPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := CompactionFailedPayload{} // Load from map diff --git a/runtime/go/prompty/model/compaction_start_payload.go b/runtime/go/prompty/model/compaction_start_payload.go index 1113823d4..1bafd8409 100644 --- a/runtime/go/prompty/model/compaction_start_payload.go +++ b/runtime/go/prompty/model/compaction_start_payload.go @@ -18,6 +18,9 @@ type CompactionStartPayload struct { // LoadCompactionStartPayload creates a CompactionStartPayload from a map[string]interface{} func LoadCompactionStartPayload(data interface{}, ctx *LoadContext) (CompactionStartPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := CompactionStartPayload{} // Load from map diff --git a/runtime/go/prompty/model/connection.go b/runtime/go/prompty/model/connection.go index 925a5efe4..617303f2e 100644 --- a/runtime/go/prompty/model/connection.go +++ b/runtime/go/prompty/model/connection.go @@ -6,7 +6,6 @@ package prompty import ( "encoding/json" - "fmt" "gopkg.in/yaml.v3" ) @@ -27,11 +26,34 @@ type Connection struct { Kind string `json:"kind" yaml:"kind"` AuthenticationMode *AuthenticationMode `json:"authenticationMode,omitempty" yaml:"authenticationMode,omitempty"` UsageDescription *string `json:"usageDescription,omitempty" yaml:"usageDescription,omitempty"` + raw map[string]interface{} +} + +func cloneConnectionRawValue(value interface{}) interface{} { + switch value := value.(type) { + case map[string]interface{}: + result := make(map[string]interface{}, len(value)) + for key, item := range value { + result[key] = cloneConnectionRawValue(item) + } + return result + case []interface{}: + result := make([]interface{}, len(value)) + for index, item := range value { + result[index] = cloneConnectionRawValue(item) + } + return result + default: + return value + } } // LoadConnection creates a Connection from a map[string]interface{} // Returns interface{} because this is a polymorphic base type that can resolve to different child types func LoadConnection(data interface{}, ctx *LoadContext) (interface{}, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := Connection{} // Handle polymorphic types based on discriminator @@ -52,15 +74,10 @@ func LoadConnection(data interface{}, ctx *LoadContext) (interface{}, error) { return LoadOAuthConnection(data, ctx) case "foundry": return LoadFoundryConnection(data, ctx) - default: - return nil, fmt.Errorf("unknown Connection discriminator value: %s", discriminator) } - default: - return nil, fmt.Errorf("unknown Connection discriminator value: %v", discriminator) } } } - return nil, fmt.Errorf("missing Connection discriminator property: kind") // Load from map if m, ok := data.(map[string]interface{}); ok { if val, ok := m["kind"]; ok && val != nil { @@ -74,6 +91,13 @@ func LoadConnection(data interface{}, ctx *LoadContext) (interface{}, error) { v := string(val.(string)) result.UsageDescription = &v } + result.raw = make(map[string]interface{}, len(m)) + for key, value := range m { + result.raw[key] = cloneConnectionRawValue(value) + } + delete(result.raw, "kind") + delete(result.raw, "authenticationMode") + delete(result.raw, "usageDescription") } return result, nil @@ -82,6 +106,9 @@ func LoadConnection(data interface{}, ctx *LoadContext) (interface{}, error) { // Save serializes Connection to map[string]interface{} func (obj Connection) Save(ctx *SaveContext) map[string]interface{} { result := make(map[string]interface{}) + for key, value := range obj.raw { + result[key] = cloneConnectionRawValue(value) + } result["kind"] = obj.Kind if obj.AuthenticationMode != nil { result["authenticationMode"] = string(*obj.AuthenticationMode) @@ -145,6 +172,9 @@ type ReferenceConnection struct { // LoadReferenceConnection creates a ReferenceConnection from a map[string]interface{} func LoadReferenceConnection(data interface{}, ctx *LoadContext) (ReferenceConnection, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ReferenceConnection{} // Load from map @@ -240,6 +270,9 @@ type RemoteConnection struct { // LoadRemoteConnection creates a RemoteConnection from a map[string]interface{} func LoadRemoteConnection(data interface{}, ctx *LoadContext) (RemoteConnection, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := RemoteConnection{} // Load from map @@ -332,6 +365,9 @@ type ApiKeyConnection struct { // LoadApiKeyConnection creates a ApiKeyConnection from a map[string]interface{} func LoadApiKeyConnection(data interface{}, ctx *LoadContext) (ApiKeyConnection, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ApiKeyConnection{} // Load from map @@ -422,6 +458,9 @@ type AnonymousConnection struct { // LoadAnonymousConnection creates a AnonymousConnection from a map[string]interface{} func LoadAnonymousConnection(data interface{}, ctx *LoadContext) (AnonymousConnection, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := AnonymousConnection{} // Load from map @@ -515,6 +554,9 @@ type OAuthConnection struct { // LoadOAuthConnection creates a OAuthConnection from a map[string]interface{} func LoadOAuthConnection(data interface{}, ctx *LoadContext) (OAuthConnection, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := OAuthConnection{} // Load from map @@ -572,7 +614,9 @@ func (obj OAuthConnection) Save(ctx *SaveContext) map[string]interface{} { result["clientId"] = obj.ClientId result["clientSecret"] = obj.ClientSecret result["tokenUrl"] = obj.TokenUrl - result["scopes"] = obj.Scopes + if obj.Scopes != nil { + result["scopes"] = obj.Scopes + } return result } @@ -630,6 +674,9 @@ type FoundryConnection struct { // LoadFoundryConnection creates a FoundryConnection from a map[string]interface{} func LoadFoundryConnection(data interface{}, ctx *LoadContext) (FoundryConnection, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := FoundryConnection{} // Load from map diff --git a/runtime/go/prompty/model/connection_roundtrip_vectors_test.go b/runtime/go/prompty/model/connection_roundtrip_vectors_test.go new file mode 100644 index 000000000..74f38d617 --- /dev/null +++ b/runtime/go/prompty/model/connection_roundtrip_vectors_test.go @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft. All rights reserved. + +package prompty_test + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "prompty/model" +) + +type connectionRoundtripVectorDocument struct { + Vectors []connectionRoundtripVector `json:"vectors"` +} + +type connectionRoundtripVector struct { + Name string `json:"name"` + Operation string `json:"operation"` + Input map[string]interface{} `json:"input"` + Expected map[string]interface{} `json:"expected"` +} + +func TestConnectionRoundtripVectorsPreserveExactDiscriminatorAndPayload(t *testing.T) { + path := filepath.Join( + "..", + "..", + "..", + "..", + "spec", + "vectors", + "model", + "connection_roundtrip_vectors.json", + ) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read Connection roundtrip vectors: %v", err) + } + + var document connectionRoundtripVectorDocument + if err := json.Unmarshal(raw, &document); err != nil { + t.Fatalf("failed to parse Connection roundtrip vectors: %v", err) + } + + for _, vector := range document.Vectors { + t.Run(vector.Name, func(t *testing.T) { + if vector.Operation != "load-save-reload" { + t.Fatalf("unsupported vector operation %q", vector.Operation) + } + + loaded, err := prompty.LoadConnection(vector.Input, prompty.NewLoadContext()) + if err != nil { + t.Fatalf("load failed: %v", err) + } + + saved := saveConnection(t, loaded) + if saved["kind"] != vector.Expected["kind"] { + t.Fatalf("save changed discriminator: expected %v, got %v", vector.Expected["kind"], saved["kind"]) + } + if !reflect.DeepEqual(saved, vector.Expected) { + t.Fatalf("save changed Connection payload:\nexpected: %#v\nactual: %#v", vector.Expected, saved) + } + + reloaded, err := prompty.LoadConnection(saved, prompty.NewLoadContext()) + if err != nil { + t.Fatalf("reload failed: %v", err) + } + resaved := saveConnection(t, reloaded) + if !reflect.DeepEqual(resaved, vector.Expected) { + t.Fatalf("reload changed Connection payload:\nexpected: %#v\nactual: %#v", vector.Expected, resaved) + } + }) + } +} + +func saveConnection(t *testing.T, connection interface{}) map[string]interface{} { + t.Helper() + + method := reflect.ValueOf(connection).MethodByName("Save") + if !method.IsValid() { + t.Fatalf("loaded Connection type %T does not expose Save", connection) + } + results := method.Call([]reflect.Value{reflect.ValueOf(prompty.NewSaveContext())}) + if len(results) != 1 { + t.Fatalf("loaded Connection type %T returned %d Save results", connection, len(results)) + } + saved, ok := results[0].Interface().(map[string]interface{}) + if !ok { + t.Fatalf("loaded Connection type %T returned unexpected Save result %T", connection, results[0].Interface()) + } + return saved +} diff --git a/runtime/go/prompty/model/content_part.go b/runtime/go/prompty/model/content_part.go index a2d649277..49bfc1a46 100644 --- a/runtime/go/prompty/model/content_part.go +++ b/runtime/go/prompty/model/content_part.go @@ -21,8 +21,9 @@ type ContentPart struct { // LoadContentPart creates a ContentPart from a map[string]interface{} // Returns interface{} because this is a polymorphic base type that can resolve to different child types func LoadContentPart(data interface{}, ctx *LoadContext) (interface{}, error) { - result := ContentPart{} - + if ctx == nil { + ctx = NewLoadContext() + } // Handle polymorphic types based on discriminator if m, ok := data.(map[string]interface{}); ok { if discriminator, ok := m["kind"]; ok { @@ -38,22 +39,14 @@ func LoadContentPart(data interface{}, ctx *LoadContext) (interface{}, error) { case "audio": return LoadAudioPart(data, ctx) default: - return nil, fmt.Errorf("unknown ContentPart discriminator value: %s", discriminator) + return nil, fmt.Errorf("unknown ContentPart discriminator field 'kind' value: %s", discriminator) } default: - return nil, fmt.Errorf("unknown ContentPart discriminator value: %v", discriminator) + return nil, fmt.Errorf("unknown ContentPart discriminator field 'kind' value: %v", discriminator) } } } return nil, fmt.Errorf("missing ContentPart discriminator property: kind") - // Load from map - if m, ok := data.(map[string]interface{}); ok { - if val, ok := m["kind"]; ok && val != nil { - result.Kind = string(val.(string)) - } - } - - return result, nil } // Save serializes ContentPart to map[string]interface{} @@ -113,6 +106,9 @@ type TextPart struct { // LoadTextPart creates a TextPart from a map[string]interface{} func LoadTextPart(data interface{}, ctx *LoadContext) (TextPart, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := TextPart{} // Load from map @@ -186,6 +182,9 @@ type ImagePart struct { // LoadImagePart creates a ImagePart from a map[string]interface{} func LoadImagePart(data interface{}, ctx *LoadContext) (ImagePart, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ImagePart{} // Load from map @@ -272,6 +271,9 @@ type FilePart struct { // LoadFilePart creates a FilePart from a map[string]interface{} func LoadFilePart(data interface{}, ctx *LoadContext) (FilePart, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := FilePart{} // Load from map @@ -351,6 +353,9 @@ type AudioPart struct { // LoadAudioPart creates a AudioPart from a map[string]interface{} func LoadAudioPart(data interface{}, ctx *LoadContext) (AudioPart, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := AudioPart{} // Load from map diff --git a/runtime/go/prompty/model/content_part_discriminator_vectors_test.go b/runtime/go/prompty/model/content_part_discriminator_vectors_test.go new file mode 100644 index 000000000..a8b95c9fb --- /dev/null +++ b/runtime/go/prompty/model/content_part_discriminator_vectors_test.go @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +package prompty_test + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "prompty/model" +) + +type contentPartDiscriminatorVectorDocument struct { + Vectors []contentPartDiscriminatorVector `json:"vectors"` +} + +type contentPartDiscriminatorVector struct { + Name string `json:"name"` + Operation string `json:"operation"` + Input map[string]interface{} `json:"input"` + Expected map[string]interface{} `json:"expected"` +} + +func TestContentPartDiscriminatorVectorsEnforceClosedCaseSensitiveKinds(t *testing.T) { + path := filepath.Join( + "..", + "..", + "..", + "..", + "spec", + "vectors", + "model", + "content_part_discriminator_vectors.json", + ) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read ContentPart discriminator vectors: %v", err) + } + + var document contentPartDiscriminatorVectorDocument + if err := json.Unmarshal(raw, &document); err != nil { + t.Fatalf("failed to parse ContentPart discriminator vectors: %v", err) + } + + for _, vector := range document.Vectors { + t.Run(vector.Name, func(t *testing.T) { + loaded, err := prompty.LoadContentPart(vector.Input, prompty.NewLoadContext()) + + switch vector.Operation { + case "load": + if err != nil { + t.Fatalf("known ContentPart failed to load: %v", err) + } + saved := saveContentPart(t, loaded) + if !reflect.DeepEqual(saved, vector.Expected) { + t.Fatalf("load/save changed ContentPart payload:\nexpected: %#v\nactual: %#v", vector.Expected, saved) + } + case "load-error": + if err == nil { + t.Fatalf("closed ContentPart accepted unknown discriminator %v", vector.Input["kind"]) + } + diagnostic := err.Error() + discriminator := vector.Expected["discriminator"].(string) + value := vector.Expected["value"].(string) + if !strings.Contains(diagnostic, discriminator) { + t.Fatalf("error did not identify discriminator %q: %s", discriminator, diagnostic) + } + if !strings.Contains(diagnostic, value) { + t.Fatalf("error did not preserve discriminator value %q: %s", value, diagnostic) + } + default: + t.Fatalf("unsupported vector operation %q", vector.Operation) + } + }) + } +} + +func saveContentPart(t *testing.T, contentPart interface{}) map[string]interface{} { + t.Helper() + + method := reflect.ValueOf(contentPart).MethodByName("Save") + if !method.IsValid() { + t.Fatalf("loaded ContentPart type %T does not expose Save", contentPart) + } + results := method.Call([]reflect.Value{reflect.ValueOf(prompty.NewSaveContext())}) + if len(results) != 1 { + t.Fatalf("loaded ContentPart type %T returned %d Save results", contentPart, len(results)) + } + saved, ok := results[0].Interface().(map[string]interface{}) + if !ok { + t.Fatalf("loaded ContentPart type %T returned unexpected Save result %T", contentPart, results[0].Interface()) + } + return saved +} diff --git a/runtime/go/prompty/model/context.go b/runtime/go/prompty/model/context.go index f18a5c982..6bfc60ff8 100644 --- a/runtime/go/prompty/model/context.go +++ b/runtime/go/prompty/model/context.go @@ -15,8 +15,7 @@ import ( // LoadContext provides context for loading operations type LoadContext struct { - // Add any context fields needed for loading - // e.g., file paths, base directories, etc. + Path string } // NewLoadContext creates a new LoadContext @@ -24,15 +23,36 @@ func NewLoadContext() *LoadContext { return &LoadContext{} } +// At creates a child context for a nested schema field. +func (ctx *LoadContext) At(segment string) *LoadContext { + if ctx == nil || ctx.Path == "" { + return &LoadContext{Path: segment} + } + return &LoadContext{Path: ctx.Path + "." + segment} +} + +// AtIndex creates a child context for an array element. Rendered with bracket +// notation (messages[3]) so an index is never confused with a map key of the +// same name, which dot-joining would make ambiguous. +func (ctx *LoadContext) AtIndex(index int) *LoadContext { + if ctx == nil { + return &LoadContext{Path: fmt.Sprintf("[%d]", index)} + } + return &LoadContext{Path: fmt.Sprintf("%s[%d]", ctx.Path, index)} +} + // SaveContext provides context for saving operations +const CollectionFormatObject = "object" +const CollectionFormatArray = "array" + type SaveContext struct { - // Add any context fields needed for saving - // e.g., output directories, formatting options, etc. + CollectionFormat string + UseShorthand bool } // NewSaveContext creates a new SaveContext func NewSaveContext() *SaveContext { - return &SaveContext{} + return &SaveContext{CollectionFormat: CollectionFormatObject, UseShorthand: true} } // ptrOf returns a pointer to the given value. Used by factory functions diff --git a/runtime/go/prompty/model/context_candidate.go b/runtime/go/prompty/model/context_candidate.go index 7266d5cb8..f80baa68a 100644 --- a/runtime/go/prompty/model/context_candidate.go +++ b/runtime/go/prompty/model/context_candidate.go @@ -16,12 +16,17 @@ type ContextCandidate struct { Id string `json:"id" yaml:"id"` Source string `json:"source" yaml:"source"` Messages []Message `json:"messages" yaml:"messages"` - Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Metadata map[string]interface{} `json:"metadata" yaml:"metadata"` } // LoadContextCandidate creates a ContextCandidate from a map[string]interface{} func LoadContextCandidate(data interface{}, ctx *LoadContext) (ContextCandidate, error) { - result := ContextCandidate{} + if ctx == nil { + ctx = NewLoadContext() + } + result := ContextCandidate{ + Messages: []Message{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -36,7 +41,7 @@ func LoadContextCandidate(data interface{}, ctx *LoadContext) (ContextCandidate, result.Messages = make([]Message, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadMessage(item, ctx) + loaded, err := LoadMessage(item, ctx.At("messages").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/context_request.go b/runtime/go/prompty/model/context_request.go index f9b461921..6c7b7df24 100644 --- a/runtime/go/prompty/model/context_request.go +++ b/runtime/go/prompty/model/context_request.go @@ -6,6 +6,7 @@ package prompty import ( "encoding/json" + "fmt" "gopkg.in/yaml.v3" ) @@ -25,10 +26,18 @@ type ContextRequest struct { // LoadContextRequest creates a ContextRequest from a map[string]interface{} func LoadContextRequest(data interface{}, ctx *LoadContext) (ContextRequest, error) { - result := ContextRequest{} + if ctx == nil { + ctx = NewLoadContext() + } + result := ContextRequest{ + Messages: []Message{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["contextState"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("contextState").Path) + } if val, ok := m["sessionId"]; ok && val != nil { result.SessionId = string(val.(string)) } @@ -57,7 +66,7 @@ func LoadContextRequest(data interface{}, ctx *LoadContext) (ContextRequest, err result.Messages = make([]Message, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadMessage(item, ctx) + loaded, err := LoadMessage(item, ctx.At("messages").AtIndex(i)) if err != nil { return result, err } @@ -82,7 +91,7 @@ func LoadContextRequest(data interface{}, ctx *LoadContext) (ContextRequest, err } if val, ok := m["contextState"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadInvocationContextState(m, ctx) + loaded, err := LoadInvocationContextState(m, ctx.At("contextState")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/custom_connection_test.go b/runtime/go/prompty/model/custom_connection_test.go new file mode 100644 index 000000000..9cc5440c4 --- /dev/null +++ b/runtime/go/prompty/model/custom_connection_test.go @@ -0,0 +1,4 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +package prompty_test diff --git a/runtime/go/prompty/model/delegated_state_reference.go b/runtime/go/prompty/model/delegated_state_reference.go index b393d415b..e2e8dd29a 100644 --- a/runtime/go/prompty/model/delegated_state_reference.go +++ b/runtime/go/prompty/model/delegated_state_reference.go @@ -16,11 +16,14 @@ type DelegatedStateReference struct { Provider string `json:"provider" yaml:"provider"` Kind string `json:"kind" yaml:"kind"` Id string `json:"id" yaml:"id"` - Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Metadata map[string]interface{} `json:"metadata" yaml:"metadata"` } // LoadDelegatedStateReference creates a DelegatedStateReference from a map[string]interface{} func LoadDelegatedStateReference(data interface{}, ctx *LoadContext) (DelegatedStateReference, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := DelegatedStateReference{} // Load from map diff --git a/runtime/go/prompty/model/device_authorization.go b/runtime/go/prompty/model/device_authorization.go index cf6fa70c0..a0a947413 100644 --- a/runtime/go/prompty/model/device_authorization.go +++ b/runtime/go/prompty/model/device_authorization.go @@ -23,6 +23,9 @@ type DeviceAuthorization struct { // LoadDeviceAuthorization creates a DeviceAuthorization from a map[string]interface{} func LoadDeviceAuthorization(data interface{}, ctx *LoadContext) (DeviceAuthorization, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := DeviceAuthorization{} // Load from map diff --git a/runtime/go/prompty/model/done_event_payload.go b/runtime/go/prompty/model/done_event_payload.go index 79c20ad26..ee085f6aa 100644 --- a/runtime/go/prompty/model/done_event_payload.go +++ b/runtime/go/prompty/model/done_event_payload.go @@ -19,6 +19,9 @@ type DoneEventPayload struct { // LoadDoneEventPayload creates a DoneEventPayload from a map[string]interface{} func LoadDoneEventPayload(data interface{}, ctx *LoadContext) (DoneEventPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := DoneEventPayload{} // Load from map @@ -31,7 +34,7 @@ func LoadDoneEventPayload(data interface{}, ctx *LoadContext) (DoneEventPayload, result.Messages = make([]Message, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadMessage(item, ctx) + loaded, err := LoadMessage(item, ctx.At("messages").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/engine_checkpoint.go b/runtime/go/prompty/model/engine_checkpoint.go index c69132be6..18a70f2bc 100644 --- a/runtime/go/prompty/model/engine_checkpoint.go +++ b/runtime/go/prompty/model/engine_checkpoint.go @@ -6,6 +6,7 @@ package prompty import ( "encoding/json" + "fmt" "gopkg.in/yaml.v3" ) @@ -29,8 +30,8 @@ type EngineCheckpoint struct { StablePrefixMessages int32 `json:"stablePrefixMessages" yaml:"stablePrefixMessages"` Inputs *interface{} `json:"inputs,omitempty" yaml:"inputs,omitempty"` ActiveInvocationId *string `json:"activeInvocationId,omitempty" yaml:"activeInvocationId,omitempty"` - PendingToolRequests []ModelToolRequest `json:"pendingToolRequests,omitempty" yaml:"pendingToolRequests,omitempty"` - CompletedToolResults []ModelToolResult `json:"completedToolResults,omitempty" yaml:"completedToolResults,omitempty"` + PendingToolRequests []ModelToolRequest `json:"pendingToolRequests" yaml:"pendingToolRequests"` + CompletedToolResults []ModelToolResult `json:"completedToolResults" yaml:"completedToolResults"` CompletedModelIterations int32 `json:"completedModelIterations" yaml:"completedModelIterations"` ReconciliationRequired bool `json:"reconciliationRequired" yaml:"reconciliationRequired"` ModelReconciliation *ModelReconciliationState `json:"modelReconciliation,omitempty" yaml:"modelReconciliation,omitempty"` @@ -40,15 +41,25 @@ type EngineCheckpoint struct { ResumeSameIteration bool `json:"resumeSameIteration" yaml:"resumeSameIteration"` PolicyAppliedForIteration bool `json:"policyAppliedForIteration" yaml:"policyAppliedForIteration"` ContextState InvocationContextState `json:"contextState" yaml:"contextState"` - Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Metadata map[string]interface{} `json:"metadata" yaml:"metadata"` } // LoadEngineCheckpoint creates a EngineCheckpoint from a map[string]interface{} func LoadEngineCheckpoint(data interface{}, ctx *LoadContext) (EngineCheckpoint, error) { - result := EngineCheckpoint{} + if ctx == nil { + ctx = NewLoadContext() + } + result := EngineCheckpoint{ + Messages: []Message{}, + PendingToolRequests: []ModelToolRequest{}, + CompletedToolResults: []ModelToolResult{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["contextState"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("contextState").Path) + } if val, ok := m["id"]; ok && val != nil { result.Id = string(val.(string)) } @@ -112,7 +123,7 @@ func LoadEngineCheckpoint(data interface{}, ctx *LoadContext) (EngineCheckpoint, result.Messages = make([]Message, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadMessage(item, ctx) + loaded, err := LoadMessage(item, ctx.At("messages").AtIndex(i)) if err != nil { return result, err } @@ -147,7 +158,7 @@ func LoadEngineCheckpoint(data interface{}, ctx *LoadContext) (EngineCheckpoint, result.PendingToolRequests = make([]ModelToolRequest, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadModelToolRequest(item, ctx) + loaded, err := LoadModelToolRequest(item, ctx.At("pendingToolRequests").AtIndex(i)) if err != nil { return result, err } @@ -161,7 +172,7 @@ func LoadEngineCheckpoint(data interface{}, ctx *LoadContext) (EngineCheckpoint, result.CompletedToolResults = make([]ModelToolResult, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadModelToolResult(item, ctx) + loaded, err := LoadModelToolResult(item, ctx.At("completedToolResults").AtIndex(i)) if err != nil { return result, err } @@ -189,7 +200,7 @@ func LoadEngineCheckpoint(data interface{}, ctx *LoadContext) (EngineCheckpoint, } if val, ok := m["modelReconciliation"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadModelReconciliationState(m, ctx) + loaded, err := LoadModelReconciliationState(m, ctx.At("modelReconciliation")) if err != nil { return result, err } @@ -204,7 +215,7 @@ func LoadEngineCheckpoint(data interface{}, ctx *LoadContext) (EngineCheckpoint, } if val, ok := m["pendingModelResponse"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadModelInvocationResponse(m, ctx) + loaded, err := LoadModelInvocationResponse(m, ctx.At("pendingModelResponse")) if err != nil { return result, err } @@ -219,7 +230,7 @@ func LoadEngineCheckpoint(data interface{}, ctx *LoadContext) (EngineCheckpoint, } if val, ok := m["contextState"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadInvocationContextState(m, ctx) + loaded, err := LoadInvocationContextState(m, ctx.At("contextState")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/engine_checkpoint_test.go b/runtime/go/prompty/model/engine_checkpoint_test.go index 28f7c57c4..80fe7f980 100644 --- a/runtime/go/prompty/model/engine_checkpoint_test.go +++ b/runtime/go/prompty/model/engine_checkpoint_test.go @@ -19,7 +19,8 @@ func TestEngineCheckpointLoadJSON(t *testing.T) { "id": "ckpt_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "runId": "run_abc123" + "runId": "run_abc123", + "contextState": {} } ` var data map[string]interface{} @@ -53,6 +54,7 @@ id: ckpt_abc123 sessionId: sess_abc123 turnId: turn_abc123 runId: run_abc123 +contextState: {} ` var data map[string]interface{} @@ -86,7 +88,8 @@ func TestEngineCheckpointFromJSON(t *testing.T) { "id": "ckpt_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "runId": "run_abc123" + "runId": "run_abc123", + "contextState": {} } ` @@ -115,6 +118,7 @@ id: ckpt_abc123 sessionId: sess_abc123 turnId: turn_abc123 runId: run_abc123 +contextState: {} ` @@ -143,7 +147,8 @@ func TestEngineCheckpointRoundtrip(t *testing.T) { "id": "ckpt_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "runId": "run_abc123" + "runId": "run_abc123", + "contextState": {} } ` var data map[string]interface{} @@ -184,7 +189,8 @@ func TestEngineCheckpointToJSON(t *testing.T) { "id": "ckpt_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "runId": "run_abc123" + "runId": "run_abc123", + "contextState": {} } ` var data map[string]interface{} @@ -232,7 +238,8 @@ func TestEngineCheckpointToYAML(t *testing.T) { "id": "ckpt_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "runId": "run_abc123" + "runId": "run_abc123", + "contextState": {} } ` var data map[string]interface{} diff --git a/runtime/go/prompty/model/engine_durability_port.go b/runtime/go/prompty/model/engine_durability_port.go new file mode 100644 index 000000000..a8a3a4445 --- /dev/null +++ b/runtime/go/prompty/model/engine_durability_port.go @@ -0,0 +1,14 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. +// Group: pipeline + +package prompty + +// EngineDurabilityPort represents Persists semantic engine events and checkpoints without runtime cancellation. + +type EngineDurabilityPort interface { + // Append — Append one semantic engine event durably + Append(event EngineEvent) error + // AppendWithCheckpoint — Atomically append semantic engine events and persist the checkpoint that reflects them + AppendWithCheckpoint(events []EngineEvent, checkpoint EngineCheckpoint) error +} diff --git a/runtime/go/prompty/model/engine_event.go b/runtime/go/prompty/model/engine_event.go index 7ab7e31ce..e329023f4 100644 --- a/runtime/go/prompty/model/engine_event.go +++ b/runtime/go/prompty/model/engine_event.go @@ -62,6 +62,9 @@ type EngineEvent struct { // LoadEngineEvent creates a EngineEvent from a map[string]interface{} func LoadEngineEvent(data interface{}, ctx *LoadContext) (EngineEvent, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := EngineEvent{} // Load from map diff --git a/runtime/go/prompty/model/engine_permission_decision.go b/runtime/go/prompty/model/engine_permission_decision.go index 7f9bcc449..3f21f0f41 100644 --- a/runtime/go/prompty/model/engine_permission_decision.go +++ b/runtime/go/prompty/model/engine_permission_decision.go @@ -15,11 +15,14 @@ import ( type EnginePermissionDecision struct { Approved bool `json:"approved" yaml:"approved"` Reason *string `json:"reason,omitempty" yaml:"reason,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Metadata map[string]interface{} `json:"metadata" yaml:"metadata"` } // LoadEnginePermissionDecision creates a EnginePermissionDecision from a map[string]interface{} func LoadEnginePermissionDecision(data interface{}, ctx *LoadContext) (EnginePermissionDecision, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := EnginePermissionDecision{} // Load from map diff --git a/runtime/go/prompty/model/engine_permission_port.go b/runtime/go/prompty/model/engine_permission_port.go new file mode 100644 index 000000000..6e9dd6cd1 --- /dev/null +++ b/runtime/go/prompty/model/engine_permission_port.go @@ -0,0 +1,14 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. +// Group: pipeline + +package prompty + +import "context" + +// EnginePermissionPort represents Authorizes model-requested tools at a runtime cancellation boundary. + +type EnginePermissionPort interface { + // Authorize — Authorize one model-requested tool before execution + Authorize(ctx context.Context, request ModelToolRequest) (EnginePermissionDecision, error) +} diff --git a/runtime/go/prompty/model/engine_post_commit_port.go b/runtime/go/prompty/model/engine_post_commit_port.go new file mode 100644 index 000000000..722f4ad07 --- /dev/null +++ b/runtime/go/prompty/model/engine_post_commit_port.go @@ -0,0 +1,14 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. +// Group: pipeline + +package prompty + +import "context" + +// EnginePostCommitPort represents Runs non-fatal host effects after a turn is durably committed. + +type EnginePostCommitPort interface { + // AfterCommit — Run one idempotent host effect after the turn is durably committed + AfterCommit(ctx context.Context, effectId string, commit TurnCommit) error +} diff --git a/runtime/go/prompty/model/engine_tool_port.go b/runtime/go/prompty/model/engine_tool_port.go new file mode 100644 index 000000000..29bf6dd89 --- /dev/null +++ b/runtime/go/prompty/model/engine_tool_port.go @@ -0,0 +1,14 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. +// Group: pipeline + +package prompty + +import "context" + +// EngineToolPort represents Executes authorized model-requested tools at a runtime cancellation boundary. + +type EngineToolPort interface { + // Execute — Execute one authorized model-requested tool + Execute(ctx context.Context, request ModelToolRequest) (ModelToolResult, error) +} diff --git a/runtime/go/prompty/model/error_event_payload.go b/runtime/go/prompty/model/error_event_payload.go index e2bc59227..58548a378 100644 --- a/runtime/go/prompty/model/error_event_payload.go +++ b/runtime/go/prompty/model/error_event_payload.go @@ -20,6 +20,9 @@ type ErrorEventPayload struct { // LoadErrorEventPayload creates a ErrorEventPayload from a map[string]interface{} func LoadErrorEventPayload(data interface{}, ctx *LoadContext) (ErrorEventPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ErrorEventPayload{} // Load from map diff --git a/runtime/go/prompty/model/executor.go b/runtime/go/prompty/model/executor.go index 8571abf7e..7ff047866 100644 --- a/runtime/go/prompty/model/executor.go +++ b/runtime/go/prompty/model/executor.go @@ -4,13 +4,15 @@ package prompty +import "context" + // Executor represents Calls an LLM provider with messages and returns the raw provider response. type Executor interface { // Execute — Call an LLM provider with messages and return the raw response - Execute(agent Prompty, messages []Message) (interface{}, error) + Execute(ctx context.Context, agent Prompty, messages []Message) (interface{}, error) // ExecuteStream — Call an LLM provider and return a streaming response. Returns a language-specific async iterable/stream of raw chunks. Not all providers support streaming; the default implementation should signal lack of support. - ExecuteStream(agent Prompty, messages []Message) (interface{}, error) + ExecuteStream(ctx context.Context, agent Prompty, messages []Message) (interface{}, error) // FormatToolMessages — Format tool call results into messages for the next iteration FormatToolMessages(rawResponse interface{}, toolCalls []ToolCall, toolResults []string, textContent *string) ([]Message, error) } diff --git a/runtime/go/prompty/model/file_not_found_error.go b/runtime/go/prompty/model/file_not_found_error.go index 7a47f4d78..26110895c 100644 --- a/runtime/go/prompty/model/file_not_found_error.go +++ b/runtime/go/prompty/model/file_not_found_error.go @@ -20,6 +20,9 @@ type FileNotFoundError struct { // LoadFileNotFoundError creates a FileNotFoundError from a map[string]interface{} func LoadFileNotFoundError(data interface{}, ctx *LoadContext) (FileNotFoundError, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := FileNotFoundError{} // Load from map diff --git a/runtime/go/prompty/model/final_output_policy_request.go b/runtime/go/prompty/model/final_output_policy_request.go index 194fd6d1f..d6f1c3bf5 100644 --- a/runtime/go/prompty/model/final_output_policy_request.go +++ b/runtime/go/prompty/model/final_output_policy_request.go @@ -23,7 +23,12 @@ type FinalOutputPolicyRequest struct { // LoadFinalOutputPolicyRequest creates a FinalOutputPolicyRequest from a map[string]interface{} func LoadFinalOutputPolicyRequest(data interface{}, ctx *LoadContext) (FinalOutputPolicyRequest, error) { - result := FinalOutputPolicyRequest{} + if ctx == nil { + ctx = NewLoadContext() + } + result := FinalOutputPolicyRequest{ + Messages: []Message{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -52,7 +57,7 @@ func LoadFinalOutputPolicyRequest(data interface{}, ctx *LoadContext) (FinalOutp result.Messages = make([]Message, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadMessage(item, ctx) + loaded, err := LoadMessage(item, ctx.At("messages").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/final_output_policy_result.go b/runtime/go/prompty/model/final_output_policy_result.go index 46ae90dca..5431dfbe3 100644 --- a/runtime/go/prompty/model/final_output_policy_result.go +++ b/runtime/go/prompty/model/final_output_policy_result.go @@ -14,11 +14,14 @@ import ( type FinalOutputPolicyResult struct { Output *interface{} `json:"output,omitempty" yaml:"output,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Metadata map[string]interface{} `json:"metadata" yaml:"metadata"` } // LoadFinalOutputPolicyResult creates a FinalOutputPolicyResult from a map[string]interface{} func LoadFinalOutputPolicyResult(data interface{}, ctx *LoadContext) (FinalOutputPolicyResult, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := FinalOutputPolicyResult{} // Load from map diff --git a/runtime/go/prompty/model/format_config.go b/runtime/go/prompty/model/format_config.go index 92c45f809..b05818b3b 100644 --- a/runtime/go/prompty/model/format_config.go +++ b/runtime/go/prompty/model/format_config.go @@ -14,12 +14,15 @@ import ( type FormatConfig struct { Kind string `json:"kind" yaml:"kind"` - Strict *bool `json:"strict,omitempty" yaml:"strict,omitempty"` - Options map[string]interface{} `json:"options,omitempty" yaml:"options,omitempty"` + Strict *bool `json:"strict" yaml:"strict"` + Options map[string]interface{} `json:"options" yaml:"options"` } // LoadFormatConfig creates a FormatConfig from a map[string]interface{} func LoadFormatConfig(data interface{}, ctx *LoadContext) (FormatConfig, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := FormatConfig{} // Handle alternate scalar representations diff --git a/runtime/go/prompty/model/function_tool_bindings_load_vector_test.go b/runtime/go/prompty/model/function_tool_bindings_load_vector_test.go new file mode 100644 index 000000000..fe94e07c9 --- /dev/null +++ b/runtime/go/prompty/model/function_tool_bindings_load_vector_test.go @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +package prompty_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" + + "prompty/model" +) + +type functionToolLoadVector struct { + Name string `json:"name"` + Expected struct { + Tools []struct { + Bindings map[string]struct { + Input string `json:"input"` + } `json:"bindings"` + } `json:"tools"` + } `json:"expected"` +} + +func TestFunctionToolBindingsLoadVector(t *testing.T) { + repositoryRoot := filepath.Join("..", "..", "..", "..") + vectorsRaw, err := os.ReadFile(filepath.Join(repositoryRoot, "spec", "vectors", "load", "load_vectors.json")) + if err != nil { + t.Fatalf("failed to read load vectors: %v", err) + } + + var vectors []functionToolLoadVector + if err := json.Unmarshal(vectorsRaw, &vectors); err != nil { + t.Fatalf("failed to parse load vectors: %v", err) + } + + var expectedBindings map[string]struct { + Input string `json:"input"` + } + for _, vector := range vectors { + if vector.Name == "tools_function_load" { + if len(vector.Expected.Tools) != 1 { + t.Fatalf("tools_function_load expected one tool, got %d", len(vector.Expected.Tools)) + } + expectedBindings = vector.Expected.Tools[0].Bindings + break + } + } + if expectedBindings == nil { + t.Fatal("tools_function_load vector is missing expected bindings") + } + + fixtureRaw, err := os.ReadFile(filepath.Join(repositoryRoot, "spec", "fixtures", "tools_function.prompty")) + if err != nil { + t.Fatalf("failed to read tools_function.prompty: %v", err) + } + sections := strings.SplitN(string(fixtureRaw), "---", 3) + if len(sections) != 3 { + t.Fatal("tools_function.prompty must contain YAML frontmatter") + } + + var frontmatter struct { + Tools []map[string]interface{} `yaml:"tools"` + } + if err := yaml.Unmarshal([]byte(sections[1]), &frontmatter); err != nil { + t.Fatalf("failed to parse tools_function.prompty frontmatter: %v", err) + } + if len(frontmatter.Tools) != 1 { + t.Fatalf("tools_function.prompty contains %d tools, expected one", len(frontmatter.Tools)) + } + + tool, err := prompty.LoadFunctionTool(frontmatter.Tools[0], prompty.NewLoadContext()) + if err != nil { + t.Fatalf("failed to load FunctionTool: %v", err) + } + for name, expected := range expectedBindings { + var actual *prompty.Binding + for index := range tool.Bindings { + if tool.Bindings[index].Name == name { + actual = &tool.Bindings[index] + break + } + } + if actual == nil { + t.Fatalf("missing binding %q", name) + } + if actual.Input != expected.Input { + t.Fatalf("binding %q input: expected %q, got %q", name, expected.Input, actual.Input) + } + } + if len(tool.Bindings) != len(expectedBindings) { + t.Fatalf("expected %d bindings, got %d", len(expectedBindings), len(tool.Bindings)) + } +} diff --git a/runtime/go/prompty/model/guardrail_result.go b/runtime/go/prompty/model/guardrail_result.go index 9373ec970..36c8e9298 100644 --- a/runtime/go/prompty/model/guardrail_result.go +++ b/runtime/go/prompty/model/guardrail_result.go @@ -22,6 +22,9 @@ type GuardrailResult struct { // LoadGuardrailResult creates a GuardrailResult from a map[string]interface{} func LoadGuardrailResult(data interface{}, ctx *LoadContext) (GuardrailResult, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := GuardrailResult{} // Load from map diff --git a/runtime/go/prompty/model/harness_context.go b/runtime/go/prompty/model/harness_context.go index adc63ec7e..314714324 100644 --- a/runtime/go/prompty/model/harness_context.go +++ b/runtime/go/prompty/model/harness_context.go @@ -22,6 +22,9 @@ type HarnessContext struct { // LoadHarnessContext creates a HarnessContext from a map[string]interface{} func LoadHarnessContext(data interface{}, ctx *LoadContext) (HarnessContext, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := HarnessContext{} // Load from map diff --git a/runtime/go/prompty/model/hook_end_payload.go b/runtime/go/prompty/model/hook_end_payload.go index 16d835cef..6bfbe2116 100644 --- a/runtime/go/prompty/model/hook_end_payload.go +++ b/runtime/go/prompty/model/hook_end_payload.go @@ -33,6 +33,9 @@ type HookEndPayload struct { // LoadHookEndPayload creates a HookEndPayload from a map[string]interface{} func LoadHookEndPayload(data interface{}, ctx *LoadContext) (HookEndPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := HookEndPayload{} // Load from map @@ -77,7 +80,7 @@ func LoadHookEndPayload(data interface{}, ctx *LoadContext) (HookEndPayload, err } if val, ok := m["redaction"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadRedactionMetadata(m, ctx) + loaded, err := LoadRedactionMetadata(m, ctx.At("redaction")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/hook_start_payload.go b/runtime/go/prompty/model/hook_start_payload.go index 01f4499d4..406221023 100644 --- a/runtime/go/prompty/model/hook_start_payload.go +++ b/runtime/go/prompty/model/hook_start_payload.go @@ -30,6 +30,9 @@ type HookStartPayload struct { // LoadHookStartPayload creates a HookStartPayload from a map[string]interface{} func LoadHookStartPayload(data interface{}, ctx *LoadContext) (HookStartPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := HookStartPayload{} // Load from map @@ -51,7 +54,7 @@ func LoadHookStartPayload(data interface{}, ctx *LoadContext) (HookStartPayload, } if val, ok := m["redaction"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadRedactionMetadata(m, ctx) + loaded, err := LoadRedactionMetadata(m, ctx.At("redaction")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/host_policy_request.go b/runtime/go/prompty/model/host_policy_request.go index 2803e43d4..27a9a2a94 100644 --- a/runtime/go/prompty/model/host_policy_request.go +++ b/runtime/go/prompty/model/host_policy_request.go @@ -23,7 +23,12 @@ type HostPolicyRequest struct { // LoadHostPolicyRequest creates a HostPolicyRequest from a map[string]interface{} func LoadHostPolicyRequest(data interface{}, ctx *LoadContext) (HostPolicyRequest, error) { - result := HostPolicyRequest{} + if ctx == nil { + ctx = NewLoadContext() + } + result := HostPolicyRequest{ + Messages: []Message{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -52,7 +57,7 @@ func LoadHostPolicyRequest(data interface{}, ctx *LoadContext) (HostPolicyReques result.Messages = make([]Message, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadMessage(item, ctx) + loaded, err := LoadMessage(item, ctx.At("messages").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/host_policy_result.go b/runtime/go/prompty/model/host_policy_result.go index ca7ff2337..107004dd8 100644 --- a/runtime/go/prompty/model/host_policy_result.go +++ b/runtime/go/prompty/model/host_policy_result.go @@ -15,12 +15,17 @@ import ( type HostPolicyResult struct { Messages []Message `json:"messages" yaml:"messages"` StablePrefixMessages int32 `json:"stablePrefixMessages" yaml:"stablePrefixMessages"` - Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Metadata map[string]interface{} `json:"metadata" yaml:"metadata"` } // LoadHostPolicyResult creates a HostPolicyResult from a map[string]interface{} func LoadHostPolicyResult(data interface{}, ctx *LoadContext) (HostPolicyResult, error) { - result := HostPolicyResult{} + if ctx == nil { + ctx = NewLoadContext() + } + result := HostPolicyResult{ + Messages: []Message{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -29,7 +34,7 @@ func LoadHostPolicyResult(data interface{}, ctx *LoadContext) (HostPolicyResult, result.Messages = make([]Message, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadMessage(item, ctx) + loaded, err := LoadMessage(item, ctx.At("messages").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/host_tool_request.go b/runtime/go/prompty/model/host_tool_request.go index 6aff94ad8..832c195f5 100644 --- a/runtime/go/prompty/model/host_tool_request.go +++ b/runtime/go/prompty/model/host_tool_request.go @@ -22,6 +22,9 @@ type HostToolRequest struct { // LoadHostToolRequest creates a HostToolRequest from a map[string]interface{} func LoadHostToolRequest(data interface{}, ctx *LoadContext) (HostToolRequest, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := HostToolRequest{} // Load from map diff --git a/runtime/go/prompty/model/host_tool_result.go b/runtime/go/prompty/model/host_tool_result.go index e64ee93bb..26e885729 100644 --- a/runtime/go/prompty/model/host_tool_result.go +++ b/runtime/go/prompty/model/host_tool_result.go @@ -26,6 +26,9 @@ type HostToolResult struct { // LoadHostToolResult creates a HostToolResult from a map[string]interface{} func LoadHostToolResult(data interface{}, ctx *LoadContext) (HostToolResult, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := HostToolResult{} // Load from map diff --git a/runtime/go/prompty/model/invocation_context_decision.go b/runtime/go/prompty/model/invocation_context_decision.go index 2ba0c7144..b2ec7c221 100644 --- a/runtime/go/prompty/model/invocation_context_decision.go +++ b/runtime/go/prompty/model/invocation_context_decision.go @@ -26,11 +26,14 @@ type InvocationContextDecision struct { Reason string `json:"reason" yaml:"reason"` Rank *int32 `json:"rank,omitempty" yaml:"rank,omitempty"` EstimatedTokens *int32 `json:"estimatedTokens,omitempty" yaml:"estimatedTokens,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Metadata map[string]interface{} `json:"metadata" yaml:"metadata"` } // LoadInvocationContextDecision creates a InvocationContextDecision from a map[string]interface{} func LoadInvocationContextDecision(data interface{}, ctx *LoadContext) (InvocationContextDecision, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := InvocationContextDecision{} // Load from map diff --git a/runtime/go/prompty/model/invocation_context_state.go b/runtime/go/prompty/model/invocation_context_state.go index f80fce75a..8b5e80b2a 100644 --- a/runtime/go/prompty/model/invocation_context_state.go +++ b/runtime/go/prompty/model/invocation_context_state.go @@ -23,12 +23,17 @@ const ( type InvocationContextState struct { Portability InvocationContextPortability `json:"portability" yaml:"portability"` - DelegatedState []DelegatedStateReference `json:"delegatedState,omitempty" yaml:"delegatedState,omitempty"` + DelegatedState []DelegatedStateReference `json:"delegatedState" yaml:"delegatedState"` } // LoadInvocationContextState creates a InvocationContextState from a map[string]interface{} func LoadInvocationContextState(data interface{}, ctx *LoadContext) (InvocationContextState, error) { - result := InvocationContextState{} + if ctx == nil { + ctx = NewLoadContext() + } + result := InvocationContextState{ + DelegatedState: []DelegatedStateReference{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -40,7 +45,7 @@ func LoadInvocationContextState(data interface{}, ctx *LoadContext) (InvocationC result.DelegatedState = make([]DelegatedStateReference, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadDelegatedStateReference(item, ctx) + loaded, err := LoadDelegatedStateReference(item, ctx.At("delegatedState").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/invocation_usage.go b/runtime/go/prompty/model/invocation_usage.go index 9f4d7dcfd..1c7637824 100644 --- a/runtime/go/prompty/model/invocation_usage.go +++ b/runtime/go/prompty/model/invocation_usage.go @@ -24,6 +24,9 @@ type InvocationUsage struct { // LoadInvocationUsage creates a InvocationUsage from a map[string]interface{} func LoadInvocationUsage(data interface{}, ctx *LoadContext) (InvocationUsage, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := InvocationUsage{} // Load from map diff --git a/runtime/go/prompty/model/invoker_error.go b/runtime/go/prompty/model/invoker_error.go index 4d784afd0..ef7e20f7d 100644 --- a/runtime/go/prompty/model/invoker_error.go +++ b/runtime/go/prompty/model/invoker_error.go @@ -22,6 +22,9 @@ type InvokerError struct { // LoadInvokerError creates a InvokerError from a map[string]interface{} func LoadInvokerError(data interface{}, ctx *LoadContext) (InvokerError, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := InvokerError{} // Load from map diff --git a/runtime/go/prompty/model/llm_complete_payload.go b/runtime/go/prompty/model/llm_complete_payload.go index 47330a31f..1f0ca840c 100644 --- a/runtime/go/prompty/model/llm_complete_payload.go +++ b/runtime/go/prompty/model/llm_complete_payload.go @@ -21,6 +21,9 @@ type LlmCompletePayload struct { // LoadLlmCompletePayload creates a LlmCompletePayload from a map[string]interface{} func LoadLlmCompletePayload(data interface{}, ctx *LoadContext) (LlmCompletePayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := LlmCompletePayload{} // Load from map @@ -35,7 +38,7 @@ func LoadLlmCompletePayload(data interface{}, ctx *LoadContext) (LlmCompletePayl } if val, ok := m["usage"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadTokenUsage(m, ctx) + loaded, err := LoadTokenUsage(m, ctx.At("usage")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/llm_start_payload.go b/runtime/go/prompty/model/llm_start_payload.go index 5f2756947..8c715a944 100644 --- a/runtime/go/prompty/model/llm_start_payload.go +++ b/runtime/go/prompty/model/llm_start_payload.go @@ -21,6 +21,9 @@ type LlmStartPayload struct { // LoadLlmStartPayload creates a LlmStartPayload from a map[string]interface{} func LoadLlmStartPayload(data interface{}, ctx *LoadContext) (LlmStartPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := LlmStartPayload{} // Load from map diff --git a/runtime/go/prompty/model/mcp_approval_mode.go b/runtime/go/prompty/model/mcp_approval_mode.go index b6f58a455..016cd6d14 100644 --- a/runtime/go/prompty/model/mcp_approval_mode.go +++ b/runtime/go/prompty/model/mcp_approval_mode.go @@ -31,6 +31,9 @@ type McpApprovalMode struct { // LoadMcpApprovalMode creates a McpApprovalMode from a map[string]interface{} func LoadMcpApprovalMode(data interface{}, ctx *LoadContext) (McpApprovalMode, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := McpApprovalMode{} // Handle alternate scalar representations @@ -76,8 +79,12 @@ func LoadMcpApprovalMode(data interface{}, ctx *LoadContext) (McpApprovalMode, e func (obj McpApprovalMode) Save(ctx *SaveContext) map[string]interface{} { result := make(map[string]interface{}) result["kind"] = string(obj.Kind) - result["alwaysRequireApprovalTools"] = obj.AlwaysRequireApprovalTools - result["neverRequireApprovalTools"] = obj.NeverRequireApprovalTools + if obj.AlwaysRequireApprovalTools != nil { + result["alwaysRequireApprovalTools"] = obj.AlwaysRequireApprovalTools + } + if obj.NeverRequireApprovalTools != nil { + result["neverRequireApprovalTools"] = obj.NeverRequireApprovalTools + } return result } diff --git a/runtime/go/prompty/model/memory_entry.go b/runtime/go/prompty/model/memory_entry.go index 2082866f2..3c8d9ed07 100644 --- a/runtime/go/prompty/model/memory_entry.go +++ b/runtime/go/prompty/model/memory_entry.go @@ -39,6 +39,9 @@ type MemoryEntry struct { // LoadMemoryEntry creates a MemoryEntry from a map[string]interface{} func LoadMemoryEntry(data interface{}, ctx *LoadContext) (MemoryEntry, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := MemoryEntry{} // Load from map @@ -77,7 +80,9 @@ func (obj MemoryEntry) Save(ctx *SaveContext) map[string]interface{} { if obj.CreatedAt != nil { result["createdAt"] = *obj.CreatedAt } - result["tags"] = obj.Tags + if obj.Tags != nil { + result["tags"] = obj.Tags + } return result } diff --git a/runtime/go/prompty/model/memory_store.go b/runtime/go/prompty/model/memory_store.go index f5561930c..77d0058fe 100644 --- a/runtime/go/prompty/model/memory_store.go +++ b/runtime/go/prompty/model/memory_store.go @@ -23,6 +23,9 @@ type MemoryStore struct { // LoadMemoryStore creates a MemoryStore from a map[string]interface{} func LoadMemoryStore(data interface{}, ctx *LoadContext) (MemoryStore, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := MemoryStore{} // Load from map @@ -32,7 +35,7 @@ func LoadMemoryStore(data interface{}, ctx *LoadContext) (MemoryStore, error) { result.Entries = make([]MemoryEntry, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadMemoryEntry(item, ctx) + loaded, err := LoadMemoryEntry(item, ctx.At("entries").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/message.go b/runtime/go/prompty/model/message.go index 9c38e8e82..12c588465 100644 --- a/runtime/go/prompty/model/message.go +++ b/runtime/go/prompty/model/message.go @@ -32,6 +32,9 @@ type Message struct { // LoadMessage creates a Message from a map[string]interface{} func LoadMessage(data interface{}, ctx *LoadContext) (Message, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := Message{} // Load from map @@ -44,7 +47,7 @@ func LoadMessage(data interface{}, ctx *LoadContext) (Message, error) { result.Parts = make([]interface{}, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadContentPart(item, ctx) + loaded, err := LoadContentPart(item, ctx.At("parts").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/messages_updated_payload.go b/runtime/go/prompty/model/messages_updated_payload.go index 79df9cdfd..73227e9d4 100644 --- a/runtime/go/prompty/model/messages_updated_payload.go +++ b/runtime/go/prompty/model/messages_updated_payload.go @@ -21,6 +21,9 @@ type MessagesUpdatedPayload struct { // LoadMessagesUpdatedPayload creates a MessagesUpdatedPayload from a map[string]interface{} func LoadMessagesUpdatedPayload(data interface{}, ctx *LoadContext) (MessagesUpdatedPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := MessagesUpdatedPayload{} // Load from map @@ -30,7 +33,7 @@ func LoadMessagesUpdatedPayload(data interface{}, ctx *LoadContext) (MessagesUpd result.Messages = make([]Message, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadMessage(item, ctx) + loaded, err := LoadMessage(item, ctx.At("messages").AtIndex(i)) if err != nil { return result, err } @@ -48,7 +51,7 @@ func LoadMessagesUpdatedPayload(data interface{}, ctx *LoadContext) (MessagesUpd result.Appended = make([]Message, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadMessage(item, ctx) + loaded, err := LoadMessage(item, ctx.At("appended").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/model.go b/runtime/go/prompty/model/model.go index cf9e0c1d8..16cdbba0f 100644 --- a/runtime/go/prompty/model/model.go +++ b/runtime/go/prompty/model/model.go @@ -34,6 +34,9 @@ type Model struct { // LoadModel creates a Model from a map[string]interface{} func LoadModel(data interface{}, ctx *LoadContext) (Model, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := Model{} // Handle alternate scalar representations @@ -58,7 +61,7 @@ func LoadModel(data interface{}, ctx *LoadContext) (Model, error) { } if val, ok := m["connection"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadConnection(m, ctx) + loaded, err := LoadConnection(m, ctx.At("connection")) if err != nil { return result, err } @@ -68,7 +71,7 @@ func LoadModel(data interface{}, ctx *LoadContext) (Model, error) { } if val, ok := m["options"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadModelOptions(m, ctx) + loaded, err := LoadModelOptions(m, ctx.At("options")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/model_info.go b/runtime/go/prompty/model/model_info.go index 4181d9f5f..db786e2eb 100644 --- a/runtime/go/prompty/model/model_info.go +++ b/runtime/go/prompty/model/model_info.go @@ -29,6 +29,9 @@ type ModelInfo struct { // LoadModelInfo creates a ModelInfo from a map[string]interface{} func LoadModelInfo(data interface{}, ctx *LoadContext) (ModelInfo, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ModelInfo{} // Load from map @@ -103,8 +106,12 @@ func (obj ModelInfo) Save(ctx *SaveContext) map[string]interface{} { if obj.ContextWindow != nil { result["contextWindow"] = *obj.ContextWindow } - result["inputModalities"] = obj.InputModalities - result["outputModalities"] = obj.OutputModalities + if obj.InputModalities != nil { + result["inputModalities"] = obj.InputModalities + } + if obj.OutputModalities != nil { + result["outputModalities"] = obj.OutputModalities + } if obj.AdditionalProperties != nil { result["additionalProperties"] = obj.AdditionalProperties } diff --git a/runtime/go/prompty/model/model_invocation_context_snapshot.go b/runtime/go/prompty/model/model_invocation_context_snapshot.go index a60e768e5..a2dd1d4b0 100644 --- a/runtime/go/prompty/model/model_invocation_context_snapshot.go +++ b/runtime/go/prompty/model/model_invocation_context_snapshot.go @@ -6,6 +6,7 @@ package prompty import ( "encoding/json" + "fmt" "gopkg.in/yaml.v3" ) @@ -21,18 +22,27 @@ type ModelInvocationContextSnapshot struct { InvocationId string `json:"invocationId" yaml:"invocationId"` Iteration int32 `json:"iteration" yaml:"iteration"` Messages []Message `json:"messages" yaml:"messages"` - Decisions []InvocationContextDecision `json:"decisions,omitempty" yaml:"decisions,omitempty"` + Decisions []InvocationContextDecision `json:"decisions" yaml:"decisions"` StablePrefixMessages int32 `json:"stablePrefixMessages" yaml:"stablePrefixMessages"` ContextState InvocationContextState `json:"contextState" yaml:"contextState"` - Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Metadata map[string]interface{} `json:"metadata" yaml:"metadata"` } // LoadModelInvocationContextSnapshot creates a ModelInvocationContextSnapshot from a map[string]interface{} func LoadModelInvocationContextSnapshot(data interface{}, ctx *LoadContext) (ModelInvocationContextSnapshot, error) { - result := ModelInvocationContextSnapshot{} + if ctx == nil { + ctx = NewLoadContext() + } + result := ModelInvocationContextSnapshot{ + Messages: []Message{}, + Decisions: []InvocationContextDecision{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["contextState"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("contextState").Path) + } if val, ok := m["id"]; ok && val != nil { result.Id = string(val.(string)) } @@ -64,7 +74,7 @@ func LoadModelInvocationContextSnapshot(data interface{}, ctx *LoadContext) (Mod result.Messages = make([]Message, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadMessage(item, ctx) + loaded, err := LoadMessage(item, ctx.At("messages").AtIndex(i)) if err != nil { return result, err } @@ -78,7 +88,7 @@ func LoadModelInvocationContextSnapshot(data interface{}, ctx *LoadContext) (Mod result.Decisions = make([]InvocationContextDecision, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadInvocationContextDecision(item, ctx) + loaded, err := LoadInvocationContextDecision(item, ctx.At("decisions").AtIndex(i)) if err != nil { return result, err } @@ -103,7 +113,7 @@ func LoadModelInvocationContextSnapshot(data interface{}, ctx *LoadContext) (Mod } if val, ok := m["contextState"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadInvocationContextState(m, ctx) + loaded, err := LoadInvocationContextState(m, ctx.At("contextState")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/model_invocation_context_snapshot_test.go b/runtime/go/prompty/model/model_invocation_context_snapshot_test.go index c3dbb9021..b5f69195a 100644 --- a/runtime/go/prompty/model/model_invocation_context_snapshot_test.go +++ b/runtime/go/prompty/model/model_invocation_context_snapshot_test.go @@ -19,7 +19,8 @@ func TestModelInvocationContextSnapshotLoadJSON(t *testing.T) { "id": "context:inv_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "invocationId": "inv_abc123" + "invocationId": "inv_abc123", + "contextState": {} } ` var data map[string]interface{} @@ -53,6 +54,7 @@ id: "context:inv_abc123" sessionId: sess_abc123 turnId: turn_abc123 invocationId: inv_abc123 +contextState: {} ` var data map[string]interface{} @@ -86,7 +88,8 @@ func TestModelInvocationContextSnapshotFromJSON(t *testing.T) { "id": "context:inv_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "invocationId": "inv_abc123" + "invocationId": "inv_abc123", + "contextState": {} } ` @@ -115,6 +118,7 @@ id: "context:inv_abc123" sessionId: sess_abc123 turnId: turn_abc123 invocationId: inv_abc123 +contextState: {} ` @@ -143,7 +147,8 @@ func TestModelInvocationContextSnapshotRoundtrip(t *testing.T) { "id": "context:inv_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "invocationId": "inv_abc123" + "invocationId": "inv_abc123", + "contextState": {} } ` var data map[string]interface{} @@ -184,7 +189,8 @@ func TestModelInvocationContextSnapshotToJSON(t *testing.T) { "id": "context:inv_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "invocationId": "inv_abc123" + "invocationId": "inv_abc123", + "contextState": {} } ` var data map[string]interface{} @@ -232,7 +238,8 @@ func TestModelInvocationContextSnapshotToYAML(t *testing.T) { "id": "context:inv_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "invocationId": "inv_abc123" + "invocationId": "inv_abc123", + "contextState": {} } ` var data map[string]interface{} diff --git a/runtime/go/prompty/model/model_invocation_request.go b/runtime/go/prompty/model/model_invocation_request.go index 140fdb031..eb3686500 100644 --- a/runtime/go/prompty/model/model_invocation_request.go +++ b/runtime/go/prompty/model/model_invocation_request.go @@ -6,6 +6,7 @@ package prompty import ( "encoding/json" + "fmt" "gopkg.in/yaml.v3" ) @@ -18,13 +19,19 @@ type ModelInvocationRequest struct { // LoadModelInvocationRequest creates a ModelInvocationRequest from a map[string]interface{} func LoadModelInvocationRequest(data interface{}, ctx *LoadContext) (ModelInvocationRequest, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ModelInvocationRequest{} // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["context"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("context").Path) + } if val, ok := m["context"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadModelInvocationContextSnapshot(m, ctx) + loaded, err := LoadModelInvocationContextSnapshot(m, ctx.At("context")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/model_invocation_response.go b/runtime/go/prompty/model/model_invocation_response.go index a10e0407a..af73fbc9e 100644 --- a/runtime/go/prompty/model/model_invocation_response.go +++ b/runtime/go/prompty/model/model_invocation_response.go @@ -18,15 +18,21 @@ import ( type ModelInvocationResponse struct { Output *interface{} `json:"output,omitempty" yaml:"output,omitempty"` Usage *InvocationUsage `json:"usage,omitempty" yaml:"usage,omitempty"` - AssistantMessages []Message `json:"assistantMessages,omitempty" yaml:"assistantMessages,omitempty"` - ToolRequests []ModelToolRequest `json:"toolRequests,omitempty" yaml:"toolRequests,omitempty"` + AssistantMessages []Message `json:"assistantMessages" yaml:"assistantMessages"` + ToolRequests []ModelToolRequest `json:"toolRequests" yaml:"toolRequests"` NextContextState *InvocationContextState `json:"nextContextState,omitempty" yaml:"nextContextState,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Metadata map[string]interface{} `json:"metadata" yaml:"metadata"` } // LoadModelInvocationResponse creates a ModelInvocationResponse from a map[string]interface{} func LoadModelInvocationResponse(data interface{}, ctx *LoadContext) (ModelInvocationResponse, error) { - result := ModelInvocationResponse{} + if ctx == nil { + ctx = NewLoadContext() + } + result := ModelInvocationResponse{ + AssistantMessages: []Message{}, + ToolRequests: []ModelToolRequest{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -35,7 +41,7 @@ func LoadModelInvocationResponse(data interface{}, ctx *LoadContext) (ModelInvoc } if val, ok := m["usage"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadInvocationUsage(m, ctx) + loaded, err := LoadInvocationUsage(m, ctx.At("usage")) if err != nil { return result, err } @@ -47,7 +53,7 @@ func LoadModelInvocationResponse(data interface{}, ctx *LoadContext) (ModelInvoc result.AssistantMessages = make([]Message, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadMessage(item, ctx) + loaded, err := LoadMessage(item, ctx.At("assistantMessages").AtIndex(i)) if err != nil { return result, err } @@ -61,7 +67,7 @@ func LoadModelInvocationResponse(data interface{}, ctx *LoadContext) (ModelInvoc result.ToolRequests = make([]ModelToolRequest, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadModelToolRequest(item, ctx) + loaded, err := LoadModelToolRequest(item, ctx.At("toolRequests").AtIndex(i)) if err != nil { return result, err } @@ -72,7 +78,7 @@ func LoadModelInvocationResponse(data interface{}, ctx *LoadContext) (ModelInvoc } if val, ok := m["nextContextState"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadInvocationContextState(m, ctx) + loaded, err := LoadInvocationContextState(m, ctx.At("nextContextState")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/model_options.go b/runtime/go/prompty/model/model_options.go index d416e9e57..2704f733d 100644 --- a/runtime/go/prompty/model/model_options.go +++ b/runtime/go/prompty/model/model_options.go @@ -27,6 +27,9 @@ type ModelOptions struct { // LoadModelOptions creates a ModelOptions from a map[string]interface{} func LoadModelOptions(data interface{}, ctx *LoadContext) (ModelOptions, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ModelOptions{} // Load from map @@ -186,7 +189,9 @@ func (obj ModelOptions) Save(ctx *SaveContext) map[string]interface{} { if obj.TopP != nil { result["topP"] = *obj.TopP } - result["stopSequences"] = obj.StopSequences + if obj.StopSequences != nil { + result["stopSequences"] = obj.StopSequences + } if obj.AllowMultipleToolCalls != nil { result["allowMultipleToolCalls"] = *obj.AllowMultipleToolCalls } diff --git a/runtime/go/prompty/model/model_reconciliation_state.go b/runtime/go/prompty/model/model_reconciliation_state.go index a799f037f..90613fdd0 100644 --- a/runtime/go/prompty/model/model_reconciliation_state.go +++ b/runtime/go/prompty/model/model_reconciliation_state.go @@ -6,6 +6,7 @@ package prompty import ( "encoding/json" + "fmt" "gopkg.in/yaml.v3" ) @@ -20,21 +21,27 @@ type ModelReconciliationState struct { Request ModelInvocationRequest `json:"request" yaml:"request"` FailedAttempt int32 `json:"failedAttempt" yaml:"failedAttempt"` Message string `json:"message" yaml:"message"` - Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Metadata map[string]interface{} `json:"metadata" yaml:"metadata"` } // LoadModelReconciliationState creates a ModelReconciliationState from a map[string]interface{} func LoadModelReconciliationState(data interface{}, ctx *LoadContext) (ModelReconciliationState, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ModelReconciliationState{} // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["request"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("request").Path) + } if val, ok := m["invocationId"]; ok && val != nil { result.InvocationId = string(val.(string)) } if val, ok := m["request"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadModelInvocationRequest(m, ctx) + loaded, err := LoadModelInvocationRequest(m, ctx.At("request")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/model_reconciliation_state_test.go b/runtime/go/prompty/model/model_reconciliation_state_test.go index 024f118f7..ff7b0eb87 100644 --- a/runtime/go/prompty/model/model_reconciliation_state_test.go +++ b/runtime/go/prompty/model/model_reconciliation_state_test.go @@ -17,7 +17,17 @@ func TestModelReconciliationStateLoadJSON(t *testing.T) { jsonData := ` { "invocationId": "inv_abc123", - "message": "provider connection dropped after request was sent" + "message": "provider connection dropped after request was sent", + "request": { + "context": { + "id": "context:inv_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "invocationId": "inv_abc123", + "iteration": 1, + "contextState": {} + } + } } ` var data map[string]interface{} @@ -43,6 +53,14 @@ func TestModelReconciliationStateLoadYAML(t *testing.T) { yamlData := ` invocationId: inv_abc123 message: provider connection dropped after request was sent +request: + context: + id: "context:inv_abc123" + sessionId: sess_abc123 + turnId: turn_abc123 + invocationId: inv_abc123 + iteration: 1 + contextState: {} ` var data map[string]interface{} @@ -68,7 +86,17 @@ func TestModelReconciliationStateFromJSON(t *testing.T) { jsonData := ` { "invocationId": "inv_abc123", - "message": "provider connection dropped after request was sent" + "message": "provider connection dropped after request was sent", + "request": { + "context": { + "id": "context:inv_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "invocationId": "inv_abc123", + "iteration": 1, + "contextState": {} + } + } } ` @@ -89,6 +117,14 @@ func TestModelReconciliationStateFromYAML(t *testing.T) { yamlData := ` invocationId: inv_abc123 message: provider connection dropped after request was sent +request: + context: + id: "context:inv_abc123" + sessionId: sess_abc123 + turnId: turn_abc123 + invocationId: inv_abc123 + iteration: 1 + contextState: {} ` @@ -109,7 +145,17 @@ func TestModelReconciliationStateRoundtrip(t *testing.T) { jsonData := ` { "invocationId": "inv_abc123", - "message": "provider connection dropped after request was sent" + "message": "provider connection dropped after request was sent", + "request": { + "context": { + "id": "context:inv_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "invocationId": "inv_abc123", + "iteration": 1, + "contextState": {} + } + } } ` var data map[string]interface{} @@ -142,7 +188,17 @@ func TestModelReconciliationStateToJSON(t *testing.T) { jsonData := ` { "invocationId": "inv_abc123", - "message": "provider connection dropped after request was sent" + "message": "provider connection dropped after request was sent", + "request": { + "context": { + "id": "context:inv_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "invocationId": "inv_abc123", + "iteration": 1, + "contextState": {} + } + } } ` var data map[string]interface{} @@ -182,7 +238,17 @@ func TestModelReconciliationStateToYAML(t *testing.T) { jsonData := ` { "invocationId": "inv_abc123", - "message": "provider connection dropped after request was sent" + "message": "provider connection dropped after request was sent", + "request": { + "context": { + "id": "context:inv_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "invocationId": "inv_abc123", + "iteration": 1, + "contextState": {} + } + } } ` var data map[string]interface{} diff --git a/runtime/go/prompty/model/model_tool_request.go b/runtime/go/prompty/model/model_tool_request.go index 6d62fbe82..9b8f16e45 100644 --- a/runtime/go/prompty/model/model_tool_request.go +++ b/runtime/go/prompty/model/model_tool_request.go @@ -19,11 +19,14 @@ type ModelToolRequest struct { Id string `json:"id" yaml:"id"` Name string `json:"name" yaml:"name"` Arguments *interface{} `json:"arguments,omitempty" yaml:"arguments,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Metadata map[string]interface{} `json:"metadata" yaml:"metadata"` } // LoadModelToolRequest creates a ModelToolRequest from a map[string]interface{} func LoadModelToolRequest(data interface{}, ctx *LoadContext) (ModelToolRequest, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ModelToolRequest{} // Load from map diff --git a/runtime/go/prompty/model/model_tool_result.go b/runtime/go/prompty/model/model_tool_result.go index 36abd0286..955bd3b82 100644 --- a/runtime/go/prompty/model/model_tool_result.go +++ b/runtime/go/prompty/model/model_tool_result.go @@ -27,11 +27,14 @@ type ModelToolResult struct { Outcome ModelToolOutcome `json:"outcome" yaml:"outcome"` Output *interface{} `json:"output,omitempty" yaml:"output,omitempty"` ErrorKind *string `json:"errorKind,omitempty" yaml:"errorKind,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Metadata map[string]interface{} `json:"metadata" yaml:"metadata"` } // LoadModelToolResult creates a ModelToolResult from a map[string]interface{} func LoadModelToolResult(data interface{}, ctx *LoadContext) (ModelToolResult, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ModelToolResult{} // Load from map diff --git a/runtime/go/prompty/model/named_collection_vectors_test.go b/runtime/go/prompty/model/named_collection_vectors_test.go new file mode 100644 index 000000000..830c5d837 --- /dev/null +++ b/runtime/go/prompty/model/named_collection_vectors_test.go @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft. All rights reserved. + +package prompty_test + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "testing" + + "prompty/model" +) + +type namedCollectionVectorDocument struct { + Vectors []namedCollectionVector `json:"vectors"` +} + +type namedCollectionVector struct { + Name string `json:"name"` + Operation string `json:"operation"` + CollectionPath string `json:"collectionPath"` + Input map[string]interface{} `json:"input"` + Expected map[string]interface{} `json:"expected"` +} + +func namedCollectionVectors(t *testing.T) []namedCollectionVector { + t.Helper() + path := filepath.Join("..", "..", "..", "..", "spec", "vectors", "model", "named_collection_vectors.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read named collection vectors: %v", err) + } + var document namedCollectionVectorDocument + if err := json.Unmarshal(raw, &document); err != nil { + t.Fatalf("failed to parse named collection vectors: %v", err) + } + if len(document.Vectors) == 0 { + t.Fatal("named collection vectors must contain a vectors array") + } + return document.Vectors +} + +func cloneEntry(entry map[string]interface{}) map[string]interface{} { + clone := make(map[string]interface{}, len(entry)+1) + for key, value := range entry { + clone[key] = value + } + return clone +} + +// semanticEntries normalizes either named-collection wire form into a comparable +// list of entries carrying an explicit name, mirroring the Rust reference test. +func semanticEntries(t *testing.T, vectorName string, collection interface{}) []map[string]interface{} { + t.Helper() + switch typed := collection.(type) { + case []interface{}: + entries := make([]map[string]interface{}, 0, len(typed)) + for index, raw := range typed { + entry, ok := raw.(map[string]interface{}) + if !ok { + t.Fatalf("[%s] array-form entry %d must be an object, got %T", vectorName, index, raw) + } + clone := cloneEntry(entry) + if _, present := clone["name"]; !present { + clone["name"] = "" + } + entries = append(entries, clone) + } + return entries + case map[string]interface{}: + names := make([]string, 0, len(typed)) + for name := range typed { + names = append(names, name) + } + sort.Strings(names) + entries := make([]map[string]interface{}, 0, len(names)) + for _, name := range names { + entry, ok := typed[name].(map[string]interface{}) + if !ok { + t.Fatalf("[%s] object-form entry %q must be an object, got %T", vectorName, name, typed[name]) + } + clone := cloneEntry(entry) + clone["name"] = name + entries = append(entries, clone) + } + return entries + default: + t.Fatalf("[%s] named collection must be an array or object, got %T", vectorName, collection) + return nil + } +} + +// assertSubset requires every field declared by the vector to be present and +// equal on the actual entry. Fields the vector does not mention are ignored. +func assertSubset(t *testing.T, actual interface{}, expected interface{}, path string) { + t.Helper() + expectedObject, isObject := expected.(map[string]interface{}) + if !isObject { + actualJSON, _ := json.Marshal(actual) + expectedJSON, _ := json.Marshal(expected) + if string(actualJSON) != string(expectedJSON) { + t.Errorf("%s: expected %s, got %s", path, expectedJSON, actualJSON) + } + return + } + actualObject, ok := actual.(map[string]interface{}) + if !ok { + t.Errorf("%s: expected an object, got %T", path, actual) + return + } + for key, expectedValue := range expectedObject { + actualValue, present := actualObject[key] + if !present { + t.Errorf("%s: missing field %q", path, key) + continue + } + assertSubset(t, actualValue, expectedValue, fmt.Sprintf("%s.%s", path, key)) + } +} + +func assertNamedCollection(t *testing.T, vectorName string, collection interface{}, expected map[string]interface{}) { + t.Helper() + + expectedFormat, ok := expected["collectionFormat"].(string) + if !ok { + t.Fatalf("[%s] roundtrip vector must declare collectionFormat", vectorName) + } + actualFormat := "object" + if _, isArray := collection.([]interface{}); isArray { + actualFormat = "array" + } + if actualFormat != expectedFormat { + t.Errorf("[%s] expected %s collection form, got %s", vectorName, expectedFormat, actualFormat) + return + } + + // wireEntries assert on the raw saved payload rather than the normalized + // entries: each is {index, absentFields}, requiring that the entry at that + // position never materializes a synthetic field such as an empty name. + if wireEntries, present := expected["wireEntries"].([]interface{}); present { + rawEntries, isArray := collection.([]interface{}) + if !isArray { + t.Fatalf("[%s] wire entry assertions require array form", vectorName) + } + for _, rawAssertion := range wireEntries { + assertion, isObject := rawAssertion.(map[string]interface{}) + if !isObject { + t.Fatalf("[%s] wire entry assertion must be an object", vectorName) + } + indexFloat, isNumber := assertion["index"].(float64) + if !isNumber { + t.Fatalf("[%s] wire entry assertion must declare a numeric index", vectorName) + } + index := int(indexFloat) + if index < 0 || index >= len(rawEntries) { + t.Errorf("[%s] wire entry index %d out of range (%d entries)", vectorName, index, len(rawEntries)) + continue + } + entry, isEntryObject := rawEntries[index].(map[string]interface{}) + if !isEntryObject { + t.Errorf("[%s] wire entry %d must be an object, got %T", vectorName, index, rawEntries[index]) + continue + } + absentFields, hasAbsent := assertion["absentFields"].([]interface{}) + if !hasAbsent { + continue + } + for _, rawField := range absentFields { + field, isString := rawField.(string) + if !isString { + t.Fatalf("[%s] wire entry absent field must be a string", vectorName) + } + if value, populated := entry[field]; populated { + t.Errorf("[%s] wire entry %d unexpectedly materialized field %q as %v", + vectorName, index, field, value) + } + } + } + } + + actualEntries := semanticEntries(t, vectorName, collection) + expectedEntries, ok := expected["entries"].([]interface{}) + if !ok { + t.Fatalf("[%s] roundtrip vector must declare entries", vectorName) + } + if len(actualEntries) != len(expectedEntries) { + t.Errorf("[%s] named collection entry count changed: expected %d, got %d", + vectorName, len(expectedEntries), len(actualEntries)) + return + } + + if absentFields, present := expected["absentEntryFields"].([]interface{}); present { + for _, entry := range actualEntries { + for _, rawField := range absentFields { + field, isString := rawField.(string) + if !isString { + t.Fatalf("[%s] absent entry field must be a string", vectorName) + } + if value, populated := entry[field]; populated { + t.Errorf("[%s] entry %v unexpectedly populated field %q with %v", + vectorName, entry["name"], field, value) + } + } + } + } + + if preserve, _ := expected["preserveOrder"].(bool); preserve { + for index, expectedEntry := range expectedEntries { + assertSubset(t, actualEntries[index], expectedEntry, fmt.Sprintf("%s.entries[%d]", vectorName, index)) + } + return + } + + actualByName := make(map[string]map[string]interface{}, len(actualEntries)) + for _, entry := range actualEntries { + name, isString := entry["name"].(string) + if !isString { + t.Fatalf("[%s] semantic entry name must be a string", vectorName) + } + actualByName[name] = entry + } + for _, rawExpected := range expectedEntries { + expectedEntry, isObject := rawExpected.(map[string]interface{}) + if !isObject { + t.Fatalf("[%s] expected entry must be an object", vectorName) + } + name, isString := expectedEntry["name"].(string) + if !isString { + t.Fatalf("[%s] expected entry name must be a string", vectorName) + } + actualEntry, found := actualByName[name] + if !found { + t.Errorf("[%s] missing named entry %q", vectorName, name) + continue + } + assertSubset(t, actualEntry, expectedEntry, fmt.Sprintf("%s.entries.%s", vectorName, name)) + } +} + +// TestNamedCollectionRoundtripVectors ports the Rust reference suite so the +// named-collection load/save/reload contract is executed against the Go +// emitted models rather than merely assumed to hold. +func TestNamedCollectionRoundtripVectors(t *testing.T) { + for _, vector := range namedCollectionVectors(t) { + if vector.Operation != "load-save-reload" { + continue + } + vector := vector + t.Run(vector.Name, func(t *testing.T) { + loaded, err := prompty.LoadPrompty(vector.Input, prompty.NewLoadContext()) + if err != nil { + t.Fatalf("[%s] valid collection failed to load: %v", vector.Name, err) + } + + saved := loaded.Save(prompty.NewSaveContext()) + collection, present := saved[vector.CollectionPath] + if !present { + t.Fatalf("[%s] missing collection %q after save", vector.Name, vector.CollectionPath) + } + assertNamedCollection(t, vector.Name, collection, vector.Expected) + + reloaded, err := prompty.LoadPrompty(saved, prompty.NewLoadContext()) + if err != nil { + t.Fatalf("[%s] saved collection failed to reload: %v", vector.Name, err) + } + resaved := reloaded.Save(prompty.NewSaveContext()) + reloadedCollection, present := resaved[vector.CollectionPath] + if !present { + t.Fatalf("[%s] reload lost collection %q", vector.Name, vector.CollectionPath) + } + assertNamedCollection(t, vector.Name, reloadedCollection, vector.Expected) + }) + } +} + +// TestNamedCollectionRejectionVectors covers the load-error half of the +// contract: array-valued entries in name-keyed object form must be rejected +// recursively rather than silently coerced. +func TestNamedCollectionRejectionVectors(t *testing.T) { + for _, vector := range namedCollectionVectors(t) { + if vector.Operation != "load-error" { + continue + } + vector := vector + t.Run(vector.Name, func(t *testing.T) { + loaded, err := prompty.LoadPrompty(vector.Input, prompty.NewLoadContext()) + if err == nil { + encoded, _ := json.Marshal(loaded) + t.Fatalf("[%s] expected rejection at %v (category %v), but load succeeded: %s", + vector.Name, vector.Expected["path"], vector.Expected["valueCategory"], encoded) + } + }) + } +} diff --git a/runtime/go/prompty/model/o_auth_token.go b/runtime/go/prompty/model/o_auth_token.go index ffb0c0a31..dee459003 100644 --- a/runtime/go/prompty/model/o_auth_token.go +++ b/runtime/go/prompty/model/o_auth_token.go @@ -22,6 +22,9 @@ type OAuthToken struct { // LoadOAuthToken creates a OAuthToken from a map[string]interface{} func LoadOAuthToken(data interface{}, ctx *LoadContext) (OAuthToken, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := OAuthToken{} // Load from map diff --git a/runtime/go/prompty/model/parser_config.go b/runtime/go/prompty/model/parser_config.go index e68656044..87d79424e 100644 --- a/runtime/go/prompty/model/parser_config.go +++ b/runtime/go/prompty/model/parser_config.go @@ -14,11 +14,14 @@ import ( type ParserConfig struct { Kind string `json:"kind" yaml:"kind"` - Options map[string]interface{} `json:"options,omitempty" yaml:"options,omitempty"` + Options map[string]interface{} `json:"options" yaml:"options"` } // LoadParserConfig creates a ParserConfig from a map[string]interface{} func LoadParserConfig(data interface{}, ctx *LoadContext) (ParserConfig, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ParserConfig{} // Handle alternate scalar representations diff --git a/runtime/go/prompty/model/permission_completed_payload.go b/runtime/go/prompty/model/permission_completed_payload.go index 0e07c4e73..540ae8f78 100644 --- a/runtime/go/prompty/model/permission_completed_payload.go +++ b/runtime/go/prompty/model/permission_completed_payload.go @@ -24,6 +24,9 @@ type PermissionCompletedPayload struct { // LoadPermissionCompletedPayload creates a PermissionCompletedPayload from a map[string]interface{} func LoadPermissionCompletedPayload(data interface{}, ctx *LoadContext) (PermissionCompletedPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := PermissionCompletedPayload{} // Load from map @@ -53,7 +56,7 @@ func LoadPermissionCompletedPayload(data interface{}, ctx *LoadContext) (Permiss } if val, ok := m["redaction"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadRedactionMetadata(m, ctx) + loaded, err := LoadRedactionMetadata(m, ctx.At("redaction")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/permission_decision.go b/runtime/go/prompty/model/permission_decision.go index ef20fbf99..fe85ce313 100644 --- a/runtime/go/prompty/model/permission_decision.go +++ b/runtime/go/prompty/model/permission_decision.go @@ -23,6 +23,9 @@ type PermissionDecision struct { // LoadPermissionDecision creates a PermissionDecision from a map[string]interface{} func LoadPermissionDecision(data interface{}, ctx *LoadContext) (PermissionDecision, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := PermissionDecision{} // Load from map diff --git a/runtime/go/prompty/model/permission_request.go b/runtime/go/prompty/model/permission_request.go index 414e95ff2..bb5b54025 100644 --- a/runtime/go/prompty/model/permission_request.go +++ b/runtime/go/prompty/model/permission_request.go @@ -25,6 +25,9 @@ type PermissionRequest struct { // LoadPermissionRequest creates a PermissionRequest from a map[string]interface{} func LoadPermissionRequest(data interface{}, ctx *LoadContext) (PermissionRequest, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := PermissionRequest{} // Load from map diff --git a/runtime/go/prompty/model/permission_requested_payload.go b/runtime/go/prompty/model/permission_requested_payload.go index 569e3ff51..4fca6d965 100644 --- a/runtime/go/prompty/model/permission_requested_payload.go +++ b/runtime/go/prompty/model/permission_requested_payload.go @@ -25,6 +25,9 @@ type PermissionRequestedPayload struct { // LoadPermissionRequestedPayload creates a PermissionRequestedPayload from a map[string]interface{} func LoadPermissionRequestedPayload(data interface{}, ctx *LoadContext) (PermissionRequestedPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := PermissionRequestedPayload{} // Load from map @@ -60,7 +63,7 @@ func LoadPermissionRequestedPayload(data interface{}, ctx *LoadContext) (Permiss } if val, ok := m["redaction"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadRedactionMetadata(m, ctx) + loaded, err := LoadRedactionMetadata(m, ctx.At("redaction")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/project_info.go b/runtime/go/prompty/model/project_info.go index d390f232e..e5a292435 100644 --- a/runtime/go/prompty/model/project_info.go +++ b/runtime/go/prompty/model/project_info.go @@ -20,6 +20,9 @@ type ProjectInfo struct { // LoadProjectInfo creates a ProjectInfo from a map[string]interface{} func LoadProjectInfo(data interface{}, ctx *LoadContext) (ProjectInfo, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ProjectInfo{} // Load from map diff --git a/runtime/go/prompty/model/prompty.go b/runtime/go/prompty/model/prompty.go index 64f9b129c..a9122a993 100644 --- a/runtime/go/prompty/model/prompty.go +++ b/runtime/go/prompty/model/prompty.go @@ -6,6 +6,9 @@ package prompty import ( "encoding/json" + "fmt" + "math" + "sort" "gopkg.in/yaml.v3" ) @@ -25,20 +28,25 @@ import ( type Prompty struct { Name string `json:"name" yaml:"name"` - DisplayName *string `json:"displayName,omitempty" yaml:"displayName,omitempty"` - Description *string `json:"description,omitempty" yaml:"description,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + DisplayName *string `json:"displayName" yaml:"displayName"` + Description *string `json:"description" yaml:"description"` + Metadata map[string]interface{} `json:"metadata" yaml:"metadata"` Inputs []interface{} `json:"inputs,omitempty" yaml:"inputs,omitempty"` Outputs []interface{} `json:"outputs,omitempty" yaml:"outputs,omitempty"` - Model Model `json:"model" yaml:"model"` - Tools []interface{} `json:"tools,omitempty" yaml:"tools,omitempty"` + Model *Model `json:"model,omitempty" yaml:"model,omitempty"` + Tools []interface{} `json:"tools" yaml:"tools"` Template *Template `json:"template,omitempty" yaml:"template,omitempty"` - Instructions *string `json:"instructions,omitempty" yaml:"instructions,omitempty"` + Instructions *string `json:"instructions" yaml:"instructions"` } // LoadPrompty creates a Prompty from a map[string]interface{} func LoadPrompty(data interface{}, ctx *LoadContext) (Prompty, error) { - result := Prompty{} + if ctx == nil { + ctx = NewLoadContext() + } + result := Prompty{ + Tools: []interface{}{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -59,11 +67,60 @@ func LoadPrompty(data interface{}, ctx *LoadContext) (Prompty, error) { } } if val, ok := m["inputs"]; ok && val != nil { - if arr, ok := val.([]interface{}); ok { + if named, ok := val.(map[string]interface{}); ok { + keys := make([]string, 0, len(named)) + for key := range named { + keys = append(keys, key) + } + sort.Strings(keys) + result.Inputs = make([]interface{}, 0, len(keys)) + for _, key := range keys { + entry := named[key] + if _, invalid := entry.([]interface{}); invalid { + return result, fmt.Errorf("%s: invalid named collection entry category array", ctx.At("inputs").At(key).Path) + } + item, ok := entry.(map[string]interface{}) + if !ok { + item = map[string]interface{}{} + switch shorthandValue := entry.(type) { + case int, int32, int64: + item["kind"] = "integer" + item["default"] = shorthandValue + case float64: + if shorthandValue == math.Trunc(shorthandValue) { + item["kind"] = "integer" + } else { + item["kind"] = "float" + } + item["default"] = shorthandValue + case string: + item["kind"] = "string" + item["default"] = shorthandValue + case bool: + item["kind"] = "boolean" + item["default"] = shorthandValue + default: + item["default"] = shorthandValue + } + } else { + copy := make(map[string]interface{}, len(item)+1) + for itemKey, itemValue := range item { + copy[itemKey] = itemValue + } + item = copy + } + item["name"] = key + loaded, err := LoadProperty(item, ctx.At("inputs").At(key)) + if err != nil { + return result, err + } + result.Inputs = append(result.Inputs, loaded) + } + } else if arr, ok := val.([]interface{}); ok { result.Inputs = make([]interface{}, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadProperty(item, ctx) + loaded, err := LoadProperty(item, ctx.At("inputs").AtIndex(i)) if err != nil { return result, err } @@ -74,11 +131,60 @@ func LoadPrompty(data interface{}, ctx *LoadContext) (Prompty, error) { } } if val, ok := m["outputs"]; ok && val != nil { - if arr, ok := val.([]interface{}); ok { + if named, ok := val.(map[string]interface{}); ok { + keys := make([]string, 0, len(named)) + for key := range named { + keys = append(keys, key) + } + sort.Strings(keys) + result.Outputs = make([]interface{}, 0, len(keys)) + for _, key := range keys { + entry := named[key] + if _, invalid := entry.([]interface{}); invalid { + return result, fmt.Errorf("%s: invalid named collection entry category array", ctx.At("outputs").At(key).Path) + } + item, ok := entry.(map[string]interface{}) + if !ok { + item = map[string]interface{}{} + switch shorthandValue := entry.(type) { + case int, int32, int64: + item["kind"] = "integer" + item["default"] = shorthandValue + case float64: + if shorthandValue == math.Trunc(shorthandValue) { + item["kind"] = "integer" + } else { + item["kind"] = "float" + } + item["default"] = shorthandValue + case string: + item["kind"] = "string" + item["default"] = shorthandValue + case bool: + item["kind"] = "boolean" + item["default"] = shorthandValue + default: + item["default"] = shorthandValue + } + } else { + copy := make(map[string]interface{}, len(item)+1) + for itemKey, itemValue := range item { + copy[itemKey] = itemValue + } + item = copy + } + item["name"] = key + loaded, err := LoadProperty(item, ctx.At("outputs").At(key)) + if err != nil { + return result, err + } + result.Outputs = append(result.Outputs, loaded) + } + } else if arr, ok := val.([]interface{}); ok { result.Outputs = make([]interface{}, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadProperty(item, ctx) + loaded, err := LoadProperty(item, ctx.At("outputs").AtIndex(i)) if err != nil { return result, err } @@ -90,25 +196,55 @@ func LoadPrompty(data interface{}, ctx *LoadContext) (Prompty, error) { } if val, ok := m["model"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadModel(m, ctx) + loaded, err := LoadModel(m, ctx.At("model")) if err != nil { return result, err } - result.Model = loaded + result.Model = &loaded } else { - loaded, err := LoadModel(val, ctx) + loaded, err := LoadModel(val, ctx.At("model")) if err != nil { return result, err } - result.Model = loaded + result.Model = &loaded } } if val, ok := m["tools"]; ok && val != nil { - if arr, ok := val.([]interface{}); ok { + if named, ok := val.(map[string]interface{}); ok { + keys := make([]string, 0, len(named)) + for key := range named { + keys = append(keys, key) + } + sort.Strings(keys) + result.Tools = make([]interface{}, 0, len(keys)) + for _, key := range keys { + entry := named[key] + if _, invalid := entry.([]interface{}); invalid { + return result, fmt.Errorf("%s: invalid named collection entry category array", ctx.At("tools").At(key).Path) + } + item, ok := entry.(map[string]interface{}) + if !ok { + item = map[string]interface{}{} + item["kind"] = entry + } else { + copy := make(map[string]interface{}, len(item)+1) + for itemKey, itemValue := range item { + copy[itemKey] = itemValue + } + item = copy + } + item["name"] = key + loaded, err := LoadTool(item, ctx.At("tools").At(key)) + if err != nil { + return result, err + } + result.Tools = append(result.Tools, loaded) + } + } else if arr, ok := val.([]interface{}); ok { result.Tools = make([]interface{}, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadTool(item, ctx) + loaded, err := LoadTool(item, ctx.At("tools").AtIndex(i)) if err != nil { return result, err } @@ -120,7 +256,7 @@ func LoadPrompty(data interface{}, ctx *LoadContext) (Prompty, error) { } if val, ok := m["template"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadTemplate(m, ctx) + loaded, err := LoadTemplate(m, ctx.At("template")) if err != nil { return result, err } @@ -162,7 +298,48 @@ func (obj Prompty) Save(ctx *SaveContext) map[string]interface{} { arr[i] = item } } - result["inputs"] = arr + seenNames := make(map[string]struct{}, len(arr)) + objectItems := make(map[string]interface{}, len(arr)) + losslessObject := true + for i, serialized := range arr { + item, ok := serialized.(map[string]interface{}) + if !ok { + losslessObject = false + continue + } + copy := make(map[string]interface{}, len(item)) + for key, value := range item { + copy[key] = value + } + name, hasName := copy["name"].(string) + if hasName && name == "" { + delete(copy, "name") + arr[i] = copy + hasName = false + } + if !hasName || name == "" { + losslessObject = false + continue + } + if _, duplicate := seenNames[name]; duplicate { + losslessObject = false + continue + } + seenNames[name] = struct{}{} + delete(copy, "name") + if (ctx == nil || ctx.UseShorthand) && len(copy) == 1 { + if shorthand, ok := copy["example"]; ok { + objectItems[name] = shorthand + continue + } + } + objectItems[name] = copy + } + if losslessObject && (ctx == nil || ctx.CollectionFormat != CollectionFormatArray) { + result["inputs"] = objectItems + } else { + result["inputs"] = arr + } } if obj.Outputs != nil { arr := make([]interface{}, len(obj.Outputs)) @@ -177,10 +354,52 @@ func (obj Prompty) Save(ctx *SaveContext) map[string]interface{} { arr[i] = item } } - result["outputs"] = arr + seenNames := make(map[string]struct{}, len(arr)) + objectItems := make(map[string]interface{}, len(arr)) + losslessObject := true + for i, serialized := range arr { + item, ok := serialized.(map[string]interface{}) + if !ok { + losslessObject = false + continue + } + copy := make(map[string]interface{}, len(item)) + for key, value := range item { + copy[key] = value + } + name, hasName := copy["name"].(string) + if hasName && name == "" { + delete(copy, "name") + arr[i] = copy + hasName = false + } + if !hasName || name == "" { + losslessObject = false + continue + } + if _, duplicate := seenNames[name]; duplicate { + losslessObject = false + continue + } + seenNames[name] = struct{}{} + delete(copy, "name") + if (ctx == nil || ctx.UseShorthand) && len(copy) == 1 { + if shorthand, ok := copy["example"]; ok { + objectItems[name] = shorthand + continue + } + } + objectItems[name] = copy + } + if losslessObject && (ctx == nil || ctx.CollectionFormat != CollectionFormatArray) { + result["outputs"] = objectItems + } else { + result["outputs"] = arr + } + } + if obj.Model != nil { + result["model"] = obj.Model.Save(ctx) } - - result["model"] = obj.Model.Save(ctx) if obj.Tools != nil { arr := make([]interface{}, len(obj.Tools)) for i, item := range obj.Tools { @@ -194,7 +413,42 @@ func (obj Prompty) Save(ctx *SaveContext) map[string]interface{} { arr[i] = item } } - result["tools"] = arr + seenNames := make(map[string]struct{}, len(arr)) + objectItems := make(map[string]interface{}, len(arr)) + losslessObject := true + for i, serialized := range arr { + item, ok := serialized.(map[string]interface{}) + if !ok { + losslessObject = false + continue + } + copy := make(map[string]interface{}, len(item)) + for key, value := range item { + copy[key] = value + } + name, hasName := copy["name"].(string) + if hasName && name == "" { + delete(copy, "name") + arr[i] = copy + hasName = false + } + if !hasName || name == "" { + losslessObject = false + continue + } + if _, duplicate := seenNames[name]; duplicate { + losslessObject = false + continue + } + seenNames[name] = struct{}{} + delete(copy, "name") + objectItems[name] = copy + } + if losslessObject && (ctx == nil || ctx.CollectionFormat != CollectionFormatArray) { + result["tools"] = objectItems + } else { + result["tools"] = arr + } } if obj.Template != nil { result["template"] = obj.Template.Save(ctx) diff --git a/runtime/go/prompty/model/prompty_test.go b/runtime/go/prompty/model/prompty_test.go index ab4a9094d..a19a88df7 100644 --- a/runtime/go/prompty/model/prompty_test.go +++ b/runtime/go/prompty/model/prompty_test.go @@ -175,26 +175,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` var data map[string]interface{} @@ -400,26 +381,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` @@ -994,26 +956,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` var data map[string]interface{} @@ -1198,26 +1141,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` @@ -1764,26 +1688,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` var data map[string]interface{} @@ -1996,26 +1901,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` @@ -2609,26 +2495,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` var data map[string]interface{} @@ -2820,26 +2687,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` @@ -3412,26 +3260,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` var data map[string]interface{} @@ -3664,26 +3493,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` @@ -4330,26 +4140,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` var data map[string]interface{} @@ -4561,26 +4352,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` @@ -5199,26 +4971,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` var data map[string]interface{} @@ -5458,26 +5211,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` @@ -6143,26 +5877,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` var data map[string]interface{} @@ -6381,26 +6096,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ` diff --git a/runtime/go/prompty/model/property.go b/runtime/go/prompty/model/property.go index cc02a1c01..c5e535fad 100644 --- a/runtime/go/prompty/model/property.go +++ b/runtime/go/prompty/model/property.go @@ -6,6 +6,9 @@ package prompty import ( "encoding/json" + "fmt" + "math" + "sort" "gopkg.in/yaml.v3" ) @@ -20,18 +23,43 @@ import ( type Property struct { Name string `json:"name" yaml:"name"` Kind string `json:"kind" yaml:"kind"` - Description *string `json:"description,omitempty" yaml:"description,omitempty"` - Required *bool `json:"required,omitempty" yaml:"required,omitempty"` - Nullable *bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` - Default *interface{} `json:"default,omitempty" yaml:"default,omitempty"` - Example *interface{} `json:"example,omitempty" yaml:"example,omitempty"` - EnumValues []interface{} `json:"enumValues,omitempty" yaml:"enumValues,omitempty"` + Description *string `json:"description" yaml:"description"` + Required *bool `json:"required" yaml:"required"` + Nullable *bool `json:"nullable" yaml:"nullable"` + Default *interface{} `json:"default" yaml:"default"` + Example *interface{} `json:"example" yaml:"example"` + EnumValues []interface{} `json:"enumValues" yaml:"enumValues"` + raw map[string]interface{} +} + +func clonePropertyRawValue(value interface{}) interface{} { + switch value := value.(type) { + case map[string]interface{}: + result := make(map[string]interface{}, len(value)) + for key, item := range value { + result[key] = clonePropertyRawValue(item) + } + return result + case []interface{}: + result := make([]interface{}, len(value)) + for index, item := range value { + result[index] = clonePropertyRawValue(item) + } + return result + default: + return value + } } // LoadProperty creates a Property from a map[string]interface{} // Returns interface{} because this is a polymorphic base type that can resolve to different child types func LoadProperty(data interface{}, ctx *LoadContext) (interface{}, error) { - result := Property{} + if ctx == nil { + ctx = NewLoadContext() + } + result := Property{ + EnumValues: []interface{}{}, + } // Handle alternate scalar representations switch v := data.(type) { @@ -51,6 +79,14 @@ func LoadProperty(data interface{}, ctx *LoadContext) (interface{}, error) { // Shorthand: string -> Property expansion := map[string]interface{}{"kind": "string", "example": v} return LoadProperty(expansion, ctx) + case float64: + // Shorthand: JSON number -> Property + if v == math.Trunc(v) { + expansion := map[string]interface{}{"kind": "integer", "example": v} + return LoadProperty(expansion, ctx) + } + expansion := map[string]interface{}{"kind": "float", "example": v} + return LoadProperty(expansion, ctx) } // Handle polymorphic types based on discriminator if m, ok := data.(map[string]interface{}); ok { @@ -64,11 +100,7 @@ func LoadProperty(data interface{}, ctx *LoadContext) (interface{}, error) { return LoadObjectProperty(data, ctx) case "union": return LoadUnionProperty(data, ctx) - default: - return result, nil } - default: - return result, nil } } } @@ -104,6 +136,18 @@ func LoadProperty(data interface{}, ctx *LoadContext) (interface{}, error) { result.EnumValues = arr } } + result.raw = make(map[string]interface{}, len(m)) + for key, value := range m { + result.raw[key] = clonePropertyRawValue(value) + } + delete(result.raw, "name") + delete(result.raw, "kind") + delete(result.raw, "description") + delete(result.raw, "required") + delete(result.raw, "nullable") + delete(result.raw, "default") + delete(result.raw, "example") + delete(result.raw, "enumValues") } return result, nil @@ -112,6 +156,9 @@ func LoadProperty(data interface{}, ctx *LoadContext) (interface{}, error) { // Save serializes Property to map[string]interface{} func (obj Property) Save(ctx *SaveContext) map[string]interface{} { result := make(map[string]interface{}) + for key, value := range obj.raw { + result[key] = clonePropertyRawValue(value) + } result["name"] = obj.Name result["kind"] = obj.Kind if obj.Description != nil { @@ -129,7 +176,9 @@ func (obj Property) Save(ctx *SaveContext) map[string]interface{} { if obj.Example != nil { result["example"] = *obj.Example } - result["enumValues"] = obj.EnumValues + if obj.EnumValues != nil { + result["enumValues"] = obj.EnumValues + } return result } @@ -180,18 +229,23 @@ func PropertyFromYAML(yamlStr string) (interface{}, error) { type ArrayProperty struct { Name string `json:"name" yaml:"name"` Kind string `json:"kind" yaml:"kind"` - Description *string `json:"description,omitempty" yaml:"description,omitempty"` - Required *bool `json:"required,omitempty" yaml:"required,omitempty"` - Nullable *bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` - Default *interface{} `json:"default,omitempty" yaml:"default,omitempty"` - Example *interface{} `json:"example,omitempty" yaml:"example,omitempty"` - EnumValues []interface{} `json:"enumValues,omitempty" yaml:"enumValues,omitempty"` - Items interface{} `json:"items" yaml:"items"` + Description *string `json:"description" yaml:"description"` + Required *bool `json:"required" yaml:"required"` + Nullable *bool `json:"nullable" yaml:"nullable"` + Default *interface{} `json:"default" yaml:"default"` + Example *interface{} `json:"example" yaml:"example"` + EnumValues []interface{} `json:"enumValues" yaml:"enumValues"` + Items interface{} `json:"items,omitempty" yaml:"items,omitempty"` } // LoadArrayProperty creates a ArrayProperty from a map[string]interface{} func LoadArrayProperty(data interface{}, ctx *LoadContext) (ArrayProperty, error) { - result := ArrayProperty{} + if ctx == nil { + ctx = NewLoadContext() + } + result := ArrayProperty{ + EnumValues: []interface{}{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -227,14 +281,14 @@ func LoadArrayProperty(data interface{}, ctx *LoadContext) (ArrayProperty, error } if val, ok := m["items"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadProperty(m, ctx) + loaded, err := LoadProperty(m, ctx.At("items")) if err != nil { return result, err } - // Polymorphic type - keep as interface{} + // Polymorphic type - keep as interface{} (no pointer needed, interface{} can be nil) result.Items = loaded } else { - loaded, err := LoadProperty(val, ctx) + loaded, err := LoadProperty(val, ctx.At("items")) if err != nil { return result, err } @@ -266,16 +320,21 @@ func (obj ArrayProperty) Save(ctx *SaveContext) map[string]interface{} { if obj.Example != nil { result["example"] = *obj.Example } - result["enumValues"] = obj.EnumValues - - // Handle polymorphic type via type switch - switch v := obj.Items.(type) { - case interface { - Save(*SaveContext) map[string]interface{} - }: - result["items"] = v.Save(ctx) - default: - result["items"] = obj.Items + if obj.EnumValues != nil { + result["enumValues"] = obj.EnumValues + } + if obj.Items != nil { + // Handle polymorphic type (stored as interface{} without pointer) + if obj.Items != nil { + switch v := obj.Items.(type) { + case interface { + Save(*SaveContext) map[string]interface{} + }: + result["items"] = v.Save(ctx) + default: + result["items"] = obj.Items + } + } } return result @@ -325,18 +384,24 @@ func ArrayPropertyFromYAML(yamlStr string) (ArrayProperty, error) { type ObjectProperty struct { Name string `json:"name" yaml:"name"` Kind string `json:"kind" yaml:"kind"` - Description *string `json:"description,omitempty" yaml:"description,omitempty"` - Required *bool `json:"required,omitempty" yaml:"required,omitempty"` - Nullable *bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` - Default *interface{} `json:"default,omitempty" yaml:"default,omitempty"` - Example *interface{} `json:"example,omitempty" yaml:"example,omitempty"` - EnumValues []interface{} `json:"enumValues,omitempty" yaml:"enumValues,omitempty"` + Description *string `json:"description" yaml:"description"` + Required *bool `json:"required" yaml:"required"` + Nullable *bool `json:"nullable" yaml:"nullable"` + Default *interface{} `json:"default" yaml:"default"` + Example *interface{} `json:"example" yaml:"example"` + EnumValues []interface{} `json:"enumValues" yaml:"enumValues"` Properties []interface{} `json:"properties" yaml:"properties"` } // LoadObjectProperty creates a ObjectProperty from a map[string]interface{} func LoadObjectProperty(data interface{}, ctx *LoadContext) (ObjectProperty, error) { - result := ObjectProperty{} + if ctx == nil { + ctx = NewLoadContext() + } + result := ObjectProperty{ + EnumValues: []interface{}{}, + Properties: []interface{}{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -371,11 +436,60 @@ func LoadObjectProperty(data interface{}, ctx *LoadContext) (ObjectProperty, err } } if val, ok := m["properties"]; ok && val != nil { - if arr, ok := val.([]interface{}); ok { + if named, ok := val.(map[string]interface{}); ok { + keys := make([]string, 0, len(named)) + for key := range named { + keys = append(keys, key) + } + sort.Strings(keys) + result.Properties = make([]interface{}, 0, len(keys)) + for _, key := range keys { + entry := named[key] + if _, invalid := entry.([]interface{}); invalid { + return result, fmt.Errorf("%s: invalid named collection entry category array", ctx.At("properties").At(key).Path) + } + item, ok := entry.(map[string]interface{}) + if !ok { + item = map[string]interface{}{} + switch shorthandValue := entry.(type) { + case int, int32, int64: + item["kind"] = "integer" + item["default"] = shorthandValue + case float64: + if shorthandValue == math.Trunc(shorthandValue) { + item["kind"] = "integer" + } else { + item["kind"] = "float" + } + item["default"] = shorthandValue + case string: + item["kind"] = "string" + item["default"] = shorthandValue + case bool: + item["kind"] = "boolean" + item["default"] = shorthandValue + default: + item["default"] = shorthandValue + } + } else { + copy := make(map[string]interface{}, len(item)+1) + for itemKey, itemValue := range item { + copy[itemKey] = itemValue + } + item = copy + } + item["name"] = key + loaded, err := LoadProperty(item, ctx.At("properties").At(key)) + if err != nil { + return result, err + } + result.Properties = append(result.Properties, loaded) + } + } else if arr, ok := val.([]interface{}); ok { result.Properties = make([]interface{}, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadProperty(item, ctx) + loaded, err := LoadProperty(item, ctx.At("properties").AtIndex(i)) if err != nil { return result, err } @@ -410,7 +524,9 @@ func (obj ObjectProperty) Save(ctx *SaveContext) map[string]interface{} { if obj.Example != nil { result["example"] = *obj.Example } - result["enumValues"] = obj.EnumValues + if obj.EnumValues != nil { + result["enumValues"] = obj.EnumValues + } if obj.Properties != nil { arr := make([]interface{}, len(obj.Properties)) for i, item := range obj.Properties { @@ -424,7 +540,48 @@ func (obj ObjectProperty) Save(ctx *SaveContext) map[string]interface{} { arr[i] = item } } - result["properties"] = arr + seenNames := make(map[string]struct{}, len(arr)) + objectItems := make(map[string]interface{}, len(arr)) + losslessObject := true + for i, serialized := range arr { + item, ok := serialized.(map[string]interface{}) + if !ok { + losslessObject = false + continue + } + copy := make(map[string]interface{}, len(item)) + for key, value := range item { + copy[key] = value + } + name, hasName := copy["name"].(string) + if hasName && name == "" { + delete(copy, "name") + arr[i] = copy + hasName = false + } + if !hasName || name == "" { + losslessObject = false + continue + } + if _, duplicate := seenNames[name]; duplicate { + losslessObject = false + continue + } + seenNames[name] = struct{}{} + delete(copy, "name") + if (ctx == nil || ctx.UseShorthand) && len(copy) == 1 { + if shorthand, ok := copy["example"]; ok { + objectItems[name] = shorthand + continue + } + } + objectItems[name] = copy + } + if losslessObject && (ctx == nil || ctx.CollectionFormat != CollectionFormatArray) { + result["properties"] = objectItems + } else { + result["properties"] = arr + } } return result @@ -479,19 +636,24 @@ func ObjectPropertyFromYAML(yamlStr string) (ObjectProperty, error) { type UnionProperty struct { Name string `json:"name" yaml:"name"` Kind string `json:"kind" yaml:"kind"` - Description *string `json:"description,omitempty" yaml:"description,omitempty"` - Required *bool `json:"required,omitempty" yaml:"required,omitempty"` - Nullable *bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` - Default *interface{} `json:"default,omitempty" yaml:"default,omitempty"` - Example *interface{} `json:"example,omitempty" yaml:"example,omitempty"` - EnumValues []interface{} `json:"enumValues,omitempty" yaml:"enumValues,omitempty"` + Description *string `json:"description" yaml:"description"` + Required *bool `json:"required" yaml:"required"` + Nullable *bool `json:"nullable" yaml:"nullable"` + Default *interface{} `json:"default" yaml:"default"` + Example *interface{} `json:"example" yaml:"example"` + EnumValues []interface{} `json:"enumValues" yaml:"enumValues"` OneOf []interface{} `json:"oneOf,omitempty" yaml:"oneOf,omitempty"` AnyOf []interface{} `json:"anyOf,omitempty" yaml:"anyOf,omitempty"` } // LoadUnionProperty creates a UnionProperty from a map[string]interface{} func LoadUnionProperty(data interface{}, ctx *LoadContext) (UnionProperty, error) { - result := UnionProperty{} + if ctx == nil { + ctx = NewLoadContext() + } + result := UnionProperty{ + EnumValues: []interface{}{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -530,7 +692,7 @@ func LoadUnionProperty(data interface{}, ctx *LoadContext) (UnionProperty, error result.OneOf = make([]interface{}, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadProperty(item, ctx) + loaded, err := LoadProperty(item, ctx.At("oneOf").AtIndex(i)) if err != nil { return result, err } @@ -545,7 +707,7 @@ func LoadUnionProperty(data interface{}, ctx *LoadContext) (UnionProperty, error result.AnyOf = make([]interface{}, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadProperty(item, ctx) + loaded, err := LoadProperty(item, ctx.At("anyOf").AtIndex(i)) if err != nil { return result, err } @@ -580,7 +742,9 @@ func (obj UnionProperty) Save(ctx *SaveContext) map[string]interface{} { if obj.Example != nil { result["example"] = *obj.Example } - result["enumValues"] = obj.EnumValues + if obj.EnumValues != nil { + result["enumValues"] = obj.EnumValues + } if obj.OneOf != nil { arr := make([]interface{}, len(obj.OneOf)) for i, item := range obj.OneOf { diff --git a/runtime/go/prompty/model/property_scalar_coercion_vectors_test.go b/runtime/go/prompty/model/property_scalar_coercion_vectors_test.go new file mode 100644 index 000000000..a50c22f61 --- /dev/null +++ b/runtime/go/prompty/model/property_scalar_coercion_vectors_test.go @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft. All rights reserved. + +package prompty_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "prompty/model" +) + +type propertyScalarCoercionVectorDocument struct { + Vectors []propertyScalarCoercionVector `json:"vectors"` +} + +type propertyScalarCoercionVector struct { + Name string `json:"name"` + Operation string `json:"operation"` + Cases []propertyScalarCoercionCase `json:"cases"` +} + +type propertyScalarCoercionCase struct { + Name string `json:"name"` + Input json.RawMessage `json:"input"` + Expected struct { + Kind string `json:"kind"` + Example interface{} `json:"example"` + } `json:"expected"` +} + +func TestAllPrimitivePropertyScalarsCoerceAtomically(t *testing.T) { + path := filepath.Join( + "..", + "..", + "..", + "..", + "spec", + "vectors", + "model", + "property_scalar_coercion_vectors.json", + ) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read Property scalar coercion vectors: %v", err) + } + + var document propertyScalarCoercionVectorDocument + if err := json.Unmarshal(raw, &document); err != nil { + t.Fatalf("failed to parse Property scalar coercion vectors: %v", err) + } + if len(document.Vectors) != 1 { + t.Fatalf("expected one atomic Property scalar coercion vector, got %d", len(document.Vectors)) + } + vector := document.Vectors[0] + if vector.Name != "all_primitive_property_scalars_coerce_atomically" || vector.Operation != "load" { + t.Fatalf("unexpected Property scalar coercion vector %q operation %q", vector.Name, vector.Operation) + } + expectedNames := []string{"string", "integer", "float", "boolean"} + if len(vector.Cases) != len(expectedNames) { + t.Fatalf("expected all four primitive scalar cases, got %d", len(vector.Cases)) + } + + for index, scalarCase := range vector.Cases { + if scalarCase.Name != expectedNames[index] { + t.Fatalf("scalar case %d: expected %q, got %q", index, expectedNames[index], scalarCase.Name) + } + loaded, err := prompty.PropertyFromJSON(string(scalarCase.Input)) + if err != nil { + t.Errorf("[%s] load failed: %v", scalarCase.Name, err) + continue + } + property, ok := loaded.(prompty.Property) + if !ok { + t.Errorf("[%s] expected Property, got %T", scalarCase.Name, loaded) + continue + } + if property.Kind != scalarCase.Expected.Kind { + t.Errorf("[%s] expected kind %q, got %q", scalarCase.Name, scalarCase.Expected.Kind, property.Kind) + continue + } + if property.Example == nil { + t.Errorf("[%s] expected example, got nil", scalarCase.Name) + continue + } + actualJSON, err := json.Marshal(*property.Example) + if err != nil { + t.Errorf("[%s] failed to encode actual example: %v", scalarCase.Name, err) + continue + } + expectedJSON, err := json.Marshal(scalarCase.Expected.Example) + if err != nil { + t.Errorf("[%s] failed to encode expected example: %v", scalarCase.Name, err) + continue + } + if string(actualJSON) != string(expectedJSON) { + t.Errorf("[%s] expected example %s, got %s", scalarCase.Name, expectedJSON, actualJSON) + } + } +} diff --git a/runtime/go/prompty/model/redacted_field.go b/runtime/go/prompty/model/redacted_field.go index 1c2821a06..fe57cc26c 100644 --- a/runtime/go/prompty/model/redacted_field.go +++ b/runtime/go/prompty/model/redacted_field.go @@ -31,6 +31,9 @@ type RedactedField struct { // LoadRedactedField creates a RedactedField from a map[string]interface{} func LoadRedactedField(data interface{}, ctx *LoadContext) (RedactedField, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := RedactedField{} // Load from map diff --git a/runtime/go/prompty/model/redaction_metadata.go b/runtime/go/prompty/model/redaction_metadata.go index ff1e7748c..dd97ab5bc 100644 --- a/runtime/go/prompty/model/redaction_metadata.go +++ b/runtime/go/prompty/model/redaction_metadata.go @@ -13,13 +13,16 @@ import ( // RedactionMetadata represents Metadata describing whether and how a payload was sanitized. type RedactionMetadata struct { - Sanitized *bool `json:"sanitized,omitempty" yaml:"sanitized,omitempty"` + Sanitized *bool `json:"sanitized" yaml:"sanitized"` Fields []RedactedField `json:"fields,omitempty" yaml:"fields,omitempty"` Policy *string `json:"policy,omitempty" yaml:"policy,omitempty"` } // LoadRedactionMetadata creates a RedactionMetadata from a map[string]interface{} func LoadRedactionMetadata(data interface{}, ctx *LoadContext) (RedactionMetadata, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := RedactionMetadata{} // Load from map @@ -33,7 +36,7 @@ func LoadRedactionMetadata(data interface{}, ctx *LoadContext) (RedactionMetadat result.Fields = make([]RedactedField, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadRedactedField(item, ctx) + loaded, err := LoadRedactedField(item, ctx.At("fields").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/replay_journal_record.go b/runtime/go/prompty/model/replay_journal_record.go index e06c76170..d2cf06240 100644 --- a/runtime/go/prompty/model/replay_journal_record.go +++ b/runtime/go/prompty/model/replay_journal_record.go @@ -51,6 +51,9 @@ type ReplayJournalRecord struct { // LoadReplayJournalRecord creates a ReplayJournalRecord from a map[string]interface{} func LoadReplayJournalRecord(data interface{}, ctx *LoadContext) (ReplayJournalRecord, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ReplayJournalRecord{} // Load from map diff --git a/runtime/go/prompty/model/replay_mismatch.go b/runtime/go/prompty/model/replay_mismatch.go index 774f7300d..f4144ff51 100644 --- a/runtime/go/prompty/model/replay_mismatch.go +++ b/runtime/go/prompty/model/replay_mismatch.go @@ -21,6 +21,9 @@ type ReplayMismatch struct { // LoadReplayMismatch creates a ReplayMismatch from a map[string]interface{} func LoadReplayMismatch(data interface{}, ctx *LoadContext) (ReplayMismatch, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ReplayMismatch{} // Load from map @@ -41,7 +44,7 @@ func LoadReplayMismatch(data interface{}, ctx *LoadContext) (ReplayMismatch, err } if val, ok := m["expected"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadReplayJournalRecord(m, ctx) + loaded, err := LoadReplayJournalRecord(m, ctx.At("expected")) if err != nil { return result, err } @@ -50,7 +53,7 @@ func LoadReplayMismatch(data interface{}, ctx *LoadContext) (ReplayMismatch, err } if val, ok := m["actual"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadReplayJournalRecord(m, ctx) + loaded, err := LoadReplayJournalRecord(m, ctx.At("actual")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/replay_verification_request.go b/runtime/go/prompty/model/replay_verification_request.go index ff2fc2867..f2fd649ef 100644 --- a/runtime/go/prompty/model/replay_verification_request.go +++ b/runtime/go/prompty/model/replay_verification_request.go @@ -19,7 +19,13 @@ type ReplayVerificationRequest struct { // LoadReplayVerificationRequest creates a ReplayVerificationRequest from a map[string]interface{} func LoadReplayVerificationRequest(data interface{}, ctx *LoadContext) (ReplayVerificationRequest, error) { - result := ReplayVerificationRequest{} + if ctx == nil { + ctx = NewLoadContext() + } + result := ReplayVerificationRequest{ + Expected: []ReplayJournalRecord{}, + Actual: []ReplayJournalRecord{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -28,7 +34,7 @@ func LoadReplayVerificationRequest(data interface{}, ctx *LoadContext) (ReplayVe result.Expected = make([]ReplayJournalRecord, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadReplayJournalRecord(item, ctx) + loaded, err := LoadReplayJournalRecord(item, ctx.At("expected").AtIndex(i)) if err != nil { return result, err } @@ -42,7 +48,7 @@ func LoadReplayVerificationRequest(data interface{}, ctx *LoadContext) (ReplayVe result.Actual = make([]ReplayJournalRecord, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadReplayJournalRecord(item, ctx) + loaded, err := LoadReplayJournalRecord(item, ctx.At("actual").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/replay_verification_result.go b/runtime/go/prompty/model/replay_verification_result.go index 44fcd847c..714ca8e83 100644 --- a/runtime/go/prompty/model/replay_verification_result.go +++ b/runtime/go/prompty/model/replay_verification_result.go @@ -22,14 +22,19 @@ const ( type ReplayVerificationResult struct { Status ReplayVerificationStatus `json:"status" yaml:"status"` - Mismatches []ReplayMismatch `json:"mismatches,omitempty" yaml:"mismatches,omitempty"` + Mismatches []ReplayMismatch `json:"mismatches" yaml:"mismatches"` ExpectedCount int32 `json:"expectedCount" yaml:"expectedCount"` ActualCount int32 `json:"actualCount" yaml:"actualCount"` } // LoadReplayVerificationResult creates a ReplayVerificationResult from a map[string]interface{} func LoadReplayVerificationResult(data interface{}, ctx *LoadContext) (ReplayVerificationResult, error) { - result := ReplayVerificationResult{} + if ctx == nil { + ctx = NewLoadContext() + } + result := ReplayVerificationResult{ + Mismatches: []ReplayMismatch{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -41,7 +46,7 @@ func LoadReplayVerificationResult(data interface{}, ctx *LoadContext) (ReplayVer result.Mismatches = make([]ReplayMismatch, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadReplayMismatch(item, ctx) + loaded, err := LoadReplayMismatch(item, ctx.At("mismatches").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/resume_context.go b/runtime/go/prompty/model/resume_context.go index 604065be7..684900203 100644 --- a/runtime/go/prompty/model/resume_context.go +++ b/runtime/go/prompty/model/resume_context.go @@ -6,6 +6,7 @@ package prompty import ( "encoding/json" + "fmt" "gopkg.in/yaml.v3" ) @@ -20,18 +21,24 @@ type ResumeContext struct { MaxIterations int32 `json:"maxIterations" yaml:"maxIterations"` MaxModelAttempts int32 `json:"maxModelAttempts" yaml:"maxModelAttempts"` LastJournalSequence int64 `json:"lastJournalSequence" yaml:"lastJournalSequence"` - Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Metadata map[string]interface{} `json:"metadata" yaml:"metadata"` } // LoadResumeContext creates a ResumeContext from a map[string]interface{} func LoadResumeContext(data interface{}, ctx *LoadContext) (ResumeContext, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ResumeContext{} // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["checkpoint"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("checkpoint").Path) + } if val, ok := m["checkpoint"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadEngineCheckpoint(m, ctx) + loaded, err := LoadEngineCheckpoint(m, ctx.At("checkpoint")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/resume_context_test.go b/runtime/go/prompty/model/resume_context_test.go index 48d24f8f4..833d9c13a 100644 --- a/runtime/go/prompty/model/resume_context_test.go +++ b/runtime/go/prompty/model/resume_context_test.go @@ -16,7 +16,16 @@ import ( func TestResumeContextLoadJSON(t *testing.T) { jsonData := ` { - "lastJournalSequence": 12 + "lastJournalSequence": 12, + "checkpoint": { + "id": "ckpt_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "runId": "run_abc123", + "iteration": 1, + "lastSequence": 1, + "contextState": {} + } } ` var data map[string]interface{} @@ -32,12 +41,32 @@ func TestResumeContextLoadJSON(t *testing.T) { if instance.LastJournalSequence != 12 { t.Errorf(`Expected LastJournalSequence to be 12, got %v`, instance.LastJournalSequence) } + if instance.Checkpoint.Id != "ckpt_abc123" { + t.Errorf(`Expected Checkpoint.Id to be "ckpt_abc123", got %v`, instance.Checkpoint.Id) + } + if instance.Checkpoint.SessionId != "sess_abc123" { + t.Errorf(`Expected Checkpoint.SessionId to be "sess_abc123", got %v`, instance.Checkpoint.SessionId) + } + if instance.Checkpoint.TurnId != "turn_abc123" { + t.Errorf(`Expected Checkpoint.TurnId to be "turn_abc123", got %v`, instance.Checkpoint.TurnId) + } + if instance.Checkpoint.RunId != "run_abc123" { + t.Errorf(`Expected Checkpoint.RunId to be "run_abc123", got %v`, instance.Checkpoint.RunId) + } } // TestResumeContextLoadYAML tests loading ResumeContext from YAML func TestResumeContextLoadYAML(t *testing.T) { yamlData := ` lastJournalSequence: 12 +checkpoint: + id: ckpt_abc123 + sessionId: sess_abc123 + turnId: turn_abc123 + runId: run_abc123 + iteration: 1 + lastSequence: 1 + contextState: {} ` var data map[string]interface{} @@ -53,13 +82,34 @@ lastJournalSequence: 12 if instance.LastJournalSequence != 12 { t.Errorf(`Expected LastJournalSequence to be 12, got %v`, instance.LastJournalSequence) } + if instance.Checkpoint.Id != "ckpt_abc123" { + t.Errorf(`Expected Checkpoint.Id to be "ckpt_abc123", got %v`, instance.Checkpoint.Id) + } + if instance.Checkpoint.SessionId != "sess_abc123" { + t.Errorf(`Expected Checkpoint.SessionId to be "sess_abc123", got %v`, instance.Checkpoint.SessionId) + } + if instance.Checkpoint.TurnId != "turn_abc123" { + t.Errorf(`Expected Checkpoint.TurnId to be "turn_abc123", got %v`, instance.Checkpoint.TurnId) + } + if instance.Checkpoint.RunId != "run_abc123" { + t.Errorf(`Expected Checkpoint.RunId to be "run_abc123", got %v`, instance.Checkpoint.RunId) + } } // TestResumeContextFromJSON tests loading ResumeContext through the generated JSON helper func TestResumeContextFromJSON(t *testing.T) { jsonData := ` { - "lastJournalSequence": 12 + "lastJournalSequence": 12, + "checkpoint": { + "id": "ckpt_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "runId": "run_abc123", + "iteration": 1, + "lastSequence": 1, + "contextState": {} + } } ` @@ -70,12 +120,32 @@ func TestResumeContextFromJSON(t *testing.T) { if instance.LastJournalSequence != 12 { t.Errorf(`Expected LastJournalSequence to be 12, got %v`, instance.LastJournalSequence) } + if instance.Checkpoint.Id != "ckpt_abc123" { + t.Errorf(`Expected Checkpoint.Id to be "ckpt_abc123", got %v`, instance.Checkpoint.Id) + } + if instance.Checkpoint.SessionId != "sess_abc123" { + t.Errorf(`Expected Checkpoint.SessionId to be "sess_abc123", got %v`, instance.Checkpoint.SessionId) + } + if instance.Checkpoint.TurnId != "turn_abc123" { + t.Errorf(`Expected Checkpoint.TurnId to be "turn_abc123", got %v`, instance.Checkpoint.TurnId) + } + if instance.Checkpoint.RunId != "run_abc123" { + t.Errorf(`Expected Checkpoint.RunId to be "run_abc123", got %v`, instance.Checkpoint.RunId) + } } // TestResumeContextFromYAML tests loading ResumeContext through the generated YAML helper func TestResumeContextFromYAML(t *testing.T) { yamlData := ` lastJournalSequence: 12 +checkpoint: + id: ckpt_abc123 + sessionId: sess_abc123 + turnId: turn_abc123 + runId: run_abc123 + iteration: 1 + lastSequence: 1 + contextState: {} ` @@ -86,13 +156,34 @@ lastJournalSequence: 12 if instance.LastJournalSequence != 12 { t.Errorf(`Expected LastJournalSequence to be 12, got %v`, instance.LastJournalSequence) } + if instance.Checkpoint.Id != "ckpt_abc123" { + t.Errorf(`Expected Checkpoint.Id to be "ckpt_abc123", got %v`, instance.Checkpoint.Id) + } + if instance.Checkpoint.SessionId != "sess_abc123" { + t.Errorf(`Expected Checkpoint.SessionId to be "sess_abc123", got %v`, instance.Checkpoint.SessionId) + } + if instance.Checkpoint.TurnId != "turn_abc123" { + t.Errorf(`Expected Checkpoint.TurnId to be "turn_abc123", got %v`, instance.Checkpoint.TurnId) + } + if instance.Checkpoint.RunId != "run_abc123" { + t.Errorf(`Expected Checkpoint.RunId to be "run_abc123", got %v`, instance.Checkpoint.RunId) + } } // TestResumeContextRoundtrip tests load -> save -> load produces equivalent data func TestResumeContextRoundtrip(t *testing.T) { jsonData := ` { - "lastJournalSequence": 12 + "lastJournalSequence": 12, + "checkpoint": { + "id": "ckpt_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "runId": "run_abc123", + "iteration": 1, + "lastSequence": 1, + "contextState": {} + } } ` var data map[string]interface{} @@ -115,13 +206,34 @@ func TestResumeContextRoundtrip(t *testing.T) { if reloaded.LastJournalSequence != 12 { t.Errorf(`Expected LastJournalSequence to be 12, got %v`, reloaded.LastJournalSequence) } + if reloaded.Checkpoint.Id != "ckpt_abc123" { + t.Errorf(`Expected Checkpoint.Id to be "ckpt_abc123", got %v`, reloaded.Checkpoint.Id) + } + if reloaded.Checkpoint.SessionId != "sess_abc123" { + t.Errorf(`Expected Checkpoint.SessionId to be "sess_abc123", got %v`, reloaded.Checkpoint.SessionId) + } + if reloaded.Checkpoint.TurnId != "turn_abc123" { + t.Errorf(`Expected Checkpoint.TurnId to be "turn_abc123", got %v`, reloaded.Checkpoint.TurnId) + } + if reloaded.Checkpoint.RunId != "run_abc123" { + t.Errorf(`Expected Checkpoint.RunId to be "run_abc123", got %v`, reloaded.Checkpoint.RunId) + } } // TestResumeContextToJSON tests that ToJSON produces valid JSON func TestResumeContextToJSON(t *testing.T) { jsonData := ` { - "lastJournalSequence": 12 + "lastJournalSequence": 12, + "checkpoint": { + "id": "ckpt_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "runId": "run_abc123", + "iteration": 1, + "lastSequence": 1, + "contextState": {} + } } ` var data map[string]interface{} @@ -151,13 +263,34 @@ func TestResumeContextToJSON(t *testing.T) { if reloaded.LastJournalSequence != 12 { t.Errorf(`Expected LastJournalSequence to be 12, got %v`, reloaded.LastJournalSequence) } + if reloaded.Checkpoint.Id != "ckpt_abc123" { + t.Errorf(`Expected Checkpoint.Id to be "ckpt_abc123", got %v`, reloaded.Checkpoint.Id) + } + if reloaded.Checkpoint.SessionId != "sess_abc123" { + t.Errorf(`Expected Checkpoint.SessionId to be "sess_abc123", got %v`, reloaded.Checkpoint.SessionId) + } + if reloaded.Checkpoint.TurnId != "turn_abc123" { + t.Errorf(`Expected Checkpoint.TurnId to be "turn_abc123", got %v`, reloaded.Checkpoint.TurnId) + } + if reloaded.Checkpoint.RunId != "run_abc123" { + t.Errorf(`Expected Checkpoint.RunId to be "run_abc123", got %v`, reloaded.Checkpoint.RunId) + } } // TestResumeContextToYAML tests that ToYAML produces valid YAML func TestResumeContextToYAML(t *testing.T) { jsonData := ` { - "lastJournalSequence": 12 + "lastJournalSequence": 12, + "checkpoint": { + "id": "ckpt_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "runId": "run_abc123", + "iteration": 1, + "lastSequence": 1, + "contextState": {} + } } ` var data map[string]interface{} @@ -187,6 +320,18 @@ func TestResumeContextToYAML(t *testing.T) { if reloaded.LastJournalSequence != 12 { t.Errorf(`Expected LastJournalSequence to be 12, got %v`, reloaded.LastJournalSequence) } + if reloaded.Checkpoint.Id != "ckpt_abc123" { + t.Errorf(`Expected Checkpoint.Id to be "ckpt_abc123", got %v`, reloaded.Checkpoint.Id) + } + if reloaded.Checkpoint.SessionId != "sess_abc123" { + t.Errorf(`Expected Checkpoint.SessionId to be "sess_abc123", got %v`, reloaded.Checkpoint.SessionId) + } + if reloaded.Checkpoint.TurnId != "turn_abc123" { + t.Errorf(`Expected Checkpoint.TurnId to be "turn_abc123", got %v`, reloaded.Checkpoint.TurnId) + } + if reloaded.Checkpoint.RunId != "run_abc123" { + t.Errorf(`Expected Checkpoint.RunId to be "run_abc123", got %v`, reloaded.Checkpoint.RunId) + } } // TestResumeContextFromJSONInvalid rejects malformed JSON instead of silently defaulting diff --git a/runtime/go/prompty/model/retry_payload.go b/runtime/go/prompty/model/retry_payload.go index f87b974e3..5f6d48a0f 100644 --- a/runtime/go/prompty/model/retry_payload.go +++ b/runtime/go/prompty/model/retry_payload.go @@ -22,6 +22,9 @@ type RetryPayload struct { // LoadRetryPayload creates a RetryPayload from a map[string]interface{} func LoadRetryPayload(data interface{}, ctx *LoadContext) (RetryPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := RetryPayload{} // Load from map diff --git a/runtime/go/prompty/model/retry_policy_request.go b/runtime/go/prompty/model/retry_policy_request.go index a990e8dba..91c670abf 100644 --- a/runtime/go/prompty/model/retry_policy_request.go +++ b/runtime/go/prompty/model/retry_policy_request.go @@ -21,6 +21,9 @@ type RetryPolicyRequest struct { // LoadRetryPolicyRequest creates a RetryPolicyRequest from a map[string]interface{} func LoadRetryPolicyRequest(data interface{}, ctx *LoadContext) (RetryPolicyRequest, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := RetryPolicyRequest{} // Load from map diff --git a/runtime/go/prompty/model/run_turn_request.go b/runtime/go/prompty/model/run_turn_request.go index f6b67f41b..8747f5f9c 100644 --- a/runtime/go/prompty/model/run_turn_request.go +++ b/runtime/go/prompty/model/run_turn_request.go @@ -15,12 +15,15 @@ import ( type RunTurnRequest struct { SessionId string `json:"sessionId" yaml:"sessionId"` TurnId string `json:"turnId" yaml:"turnId"` - Inputs map[string]interface{} `json:"inputs,omitempty" yaml:"inputs,omitempty"` + Inputs map[string]interface{} `json:"inputs" yaml:"inputs"` Options *TurnOptions `json:"options,omitempty" yaml:"options,omitempty"` } // LoadRunTurnRequest creates a RunTurnRequest from a map[string]interface{} func LoadRunTurnRequest(data interface{}, ctx *LoadContext) (RunTurnRequest, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := RunTurnRequest{} // Load from map @@ -38,7 +41,7 @@ func LoadRunTurnRequest(data interface{}, ctx *LoadContext) (RunTurnRequest, err } if val, ok := m["options"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadTurnOptions(m, ctx) + loaded, err := LoadTurnOptions(m, ctx.At("options")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/run_turn_result.go b/runtime/go/prompty/model/run_turn_result.go index 7a4d94490..2419e2c86 100644 --- a/runtime/go/prompty/model/run_turn_result.go +++ b/runtime/go/prompty/model/run_turn_result.go @@ -27,13 +27,19 @@ type RunTurnResult struct { Status RunTurnStatus `json:"status" yaml:"status"` Output *interface{} `json:"output,omitempty" yaml:"output,omitempty"` Iterations int32 `json:"iterations" yaml:"iterations"` - ToolResults []HostToolResult `json:"toolResults,omitempty" yaml:"toolResults,omitempty"` - Checkpoints []Checkpoint `json:"checkpoints,omitempty" yaml:"checkpoints,omitempty"` + ToolResults []HostToolResult `json:"toolResults" yaml:"toolResults"` + Checkpoints []Checkpoint `json:"checkpoints" yaml:"checkpoints"` } // LoadRunTurnResult creates a RunTurnResult from a map[string]interface{} func LoadRunTurnResult(data interface{}, ctx *LoadContext) (RunTurnResult, error) { - result := RunTurnResult{} + if ctx == nil { + ctx = NewLoadContext() + } + result := RunTurnResult{ + ToolResults: []HostToolResult{}, + Checkpoints: []Checkpoint{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -68,7 +74,7 @@ func LoadRunTurnResult(data interface{}, ctx *LoadContext) (RunTurnResult, error result.ToolResults = make([]HostToolResult, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadHostToolResult(item, ctx) + loaded, err := LoadHostToolResult(item, ctx.At("toolResults").AtIndex(i)) if err != nil { return result, err } @@ -82,7 +88,7 @@ func LoadRunTurnResult(data interface{}, ctx *LoadContext) (RunTurnResult, error result.Checkpoints = make([]Checkpoint, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadCheckpoint(item, ctx) + loaded, err := LoadCheckpoint(item, ctx.At("checkpoints").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/session_end_payload.go b/runtime/go/prompty/model/session_end_payload.go index 76599b375..41433ff31 100644 --- a/runtime/go/prompty/model/session_end_payload.go +++ b/runtime/go/prompty/model/session_end_payload.go @@ -31,6 +31,9 @@ type SessionEndPayload struct { // LoadSessionEndPayload creates a SessionEndPayload from a map[string]interface{} func LoadSessionEndPayload(data interface{}, ctx *LoadContext) (SessionEndPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := SessionEndPayload{} // Load from map diff --git a/runtime/go/prompty/model/session_event.go b/runtime/go/prompty/model/session_event.go index 842ba3fbc..8de5e0377 100644 --- a/runtime/go/prompty/model/session_event.go +++ b/runtime/go/prompty/model/session_event.go @@ -39,6 +39,9 @@ type SessionEvent struct { // LoadSessionEvent creates a SessionEvent from a map[string]interface{} func LoadSessionEvent(data interface{}, ctx *LoadContext) (SessionEvent, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := SessionEvent{} // Load from map @@ -75,7 +78,7 @@ func LoadSessionEvent(data interface{}, ctx *LoadContext) (SessionEvent, error) } if val, ok := m["redaction"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadRedactionMetadata(m, ctx) + loaded, err := LoadRedactionMetadata(m, ctx.At("redaction")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/session_file_ref.go b/runtime/go/prompty/model/session_file_ref.go index d120057cd..f5bb1c66e 100644 --- a/runtime/go/prompty/model/session_file_ref.go +++ b/runtime/go/prompty/model/session_file_ref.go @@ -22,6 +22,9 @@ type SessionFileRef struct { // LoadSessionFileRef creates a SessionFileRef from a map[string]interface{} func LoadSessionFileRef(data interface{}, ctx *LoadContext) (SessionFileRef, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := SessionFileRef{} // Load from map diff --git a/runtime/go/prompty/model/session_ref.go b/runtime/go/prompty/model/session_ref.go index f262df1c0..33d49c1a9 100644 --- a/runtime/go/prompty/model/session_ref.go +++ b/runtime/go/prompty/model/session_ref.go @@ -22,6 +22,9 @@ type SessionRef struct { // LoadSessionRef creates a SessionRef from a map[string]interface{} func LoadSessionRef(data interface{}, ctx *LoadContext) (SessionRef, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := SessionRef{} // Load from map diff --git a/runtime/go/prompty/model/session_start_payload.go b/runtime/go/prompty/model/session_start_payload.go index c092076bc..4ef9a0cb4 100644 --- a/runtime/go/prompty/model/session_start_payload.go +++ b/runtime/go/prompty/model/session_start_payload.go @@ -14,7 +14,7 @@ import ( type SessionStartPayload struct { SessionId string `json:"sessionId" yaml:"sessionId"` - SchemaVersion *string `json:"schemaVersion,omitempty" yaml:"schemaVersion,omitempty"` + SchemaVersion *string `json:"schemaVersion" yaml:"schemaVersion"` Producer *string `json:"producer,omitempty" yaml:"producer,omitempty"` Runtime *string `json:"runtime,omitempty" yaml:"runtime,omitempty"` PromptyVersion *string `json:"promptyVersion,omitempty" yaml:"promptyVersion,omitempty"` @@ -26,6 +26,9 @@ type SessionStartPayload struct { // LoadSessionStartPayload creates a SessionStartPayload from a map[string]interface{} func LoadSessionStartPayload(data interface{}, ctx *LoadContext) (SessionStartPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := SessionStartPayload{} // Load from map @@ -63,7 +66,7 @@ func LoadSessionStartPayload(data interface{}, ctx *LoadContext) (SessionStartPa } if val, ok := m["context"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadHarnessContext(m, ctx) + loaded, err := LoadHarnessContext(m, ctx.At("context")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/session_summary.go b/runtime/go/prompty/model/session_summary.go index 2a44f19e6..dd81f4428 100644 --- a/runtime/go/prompty/model/session_summary.go +++ b/runtime/go/prompty/model/session_summary.go @@ -33,6 +33,9 @@ type SessionSummary struct { // LoadSessionSummary creates a SessionSummary from a map[string]interface{} func LoadSessionSummary(data interface{}, ctx *LoadContext) (SessionSummary, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := SessionSummary{} // Load from map @@ -74,7 +77,7 @@ func LoadSessionSummary(data interface{}, ctx *LoadContext) (SessionSummary, err } if val, ok := m["usage"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadTokenUsage(m, ctx) + loaded, err := LoadTokenUsage(m, ctx.At("usage")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/session_trace.go b/runtime/go/prompty/model/session_trace.go index 704cfbf94..71477132d 100644 --- a/runtime/go/prompty/model/session_trace.go +++ b/runtime/go/prompty/model/session_trace.go @@ -28,6 +28,9 @@ type SessionTrace struct { // LoadSessionTrace creates a SessionTrace from a map[string]interface{} func LoadSessionTrace(data interface{}, ctx *LoadContext) (SessionTrace, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := SessionTrace{} // Load from map @@ -52,7 +55,7 @@ func LoadSessionTrace(data interface{}, ctx *LoadContext) (SessionTrace, error) result.Events = make([]SessionEvent, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadSessionEvent(item, ctx) + loaded, err := LoadSessionEvent(item, ctx.At("events").AtIndex(i)) if err != nil { return result, err } @@ -66,7 +69,7 @@ func LoadSessionTrace(data interface{}, ctx *LoadContext) (SessionTrace, error) result.Turns = make([]TurnTrace, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadTurnTrace(item, ctx) + loaded, err := LoadTurnTrace(item, ctx.At("turns").AtIndex(i)) if err != nil { return result, err } @@ -80,7 +83,7 @@ func LoadSessionTrace(data interface{}, ctx *LoadContext) (SessionTrace, error) result.Checkpoints = make([]Checkpoint, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadCheckpoint(item, ctx) + loaded, err := LoadCheckpoint(item, ctx.At("checkpoints").AtIndex(i)) if err != nil { return result, err } @@ -94,7 +97,7 @@ func LoadSessionTrace(data interface{}, ctx *LoadContext) (SessionTrace, error) result.Trajectory = make([]TrajectoryEvent, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadTrajectoryEvent(item, ctx) + loaded, err := LoadTrajectoryEvent(item, ctx.At("trajectory").AtIndex(i)) if err != nil { return result, err } @@ -108,7 +111,7 @@ func LoadSessionTrace(data interface{}, ctx *LoadContext) (SessionTrace, error) result.Files = make([]SessionFileRef, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadSessionFileRef(item, ctx) + loaded, err := LoadSessionFileRef(item, ctx.At("files").AtIndex(i)) if err != nil { return result, err } @@ -122,7 +125,7 @@ func LoadSessionTrace(data interface{}, ctx *LoadContext) (SessionTrace, error) result.Refs = make([]SessionRef, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadSessionRef(item, ctx) + loaded, err := LoadSessionRef(item, ctx.At("refs").AtIndex(i)) if err != nil { return result, err } @@ -133,7 +136,7 @@ func LoadSessionTrace(data interface{}, ctx *LoadContext) (SessionTrace, error) } if val, ok := m["summary"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadSessionSummary(m, ctx) + loaded, err := LoadSessionSummary(m, ctx.At("summary")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/session_trace_test.go b/runtime/go/prompty/model/session_trace_test.go index 0c6403ee1..2c4e6583e 100644 --- a/runtime/go/prompty/model/session_trace_test.go +++ b/runtime/go/prompty/model/session_trace_test.go @@ -5,6 +5,7 @@ package prompty_test import ( "encoding/json" + "reflect" "testing" "gopkg.in/yaml.v3" @@ -19,7 +20,18 @@ func TestSessionTraceLoadJSON(t *testing.T) { "version": "1", "runtime": "typescript", "promptyVersion": "2.0.0", - "sessionId": "sess_abc123" + "sessionId": "sess_abc123", + "events": [ + { + "id": "evt_abc123", + "type": "session_start", + "timestamp": "2026-06-09T20:00:00Z", + "sessionId": "sess_abc123", + "turnId": "turn_001", + "parentId": "evt_parent", + "spanId": "span_hook_001" + } + ] } ` var data map[string]interface{} @@ -44,6 +56,16 @@ func TestSessionTraceLoadJSON(t *testing.T) { if instance.SessionId == nil || *instance.SessionId != "sess_abc123" { t.Errorf(`Expected SessionId to be "sess_abc123", got %v`, instance.SessionId) } + if len(instance.Events) != 1 { + t.Fatalf("Expected Events length to be 1, got %d", len(instance.Events)) + } + assertSessionTraceStringField(t, instance.Events[0], "Id", "evt_abc123", "Events[0].Id") + assertSessionTraceStringField(t, instance.Events[0], "Type", "session_start", "Events[0].Type") + assertSessionTraceStringField(t, instance.Events[0], "Timestamp", "2026-06-09T20:00:00Z", "Events[0].Timestamp") + assertSessionTraceStringField(t, instance.Events[0], "SessionId", "sess_abc123", "Events[0].SessionId") + assertSessionTraceStringField(t, instance.Events[0], "TurnId", "turn_001", "Events[0].TurnId") + assertSessionTraceStringField(t, instance.Events[0], "ParentId", "evt_parent", "Events[0].ParentId") + assertSessionTraceStringField(t, instance.Events[0], "SpanId", "span_hook_001", "Events[0].SpanId") } // TestSessionTraceLoadYAML tests loading SessionTrace from YAML @@ -53,6 +75,14 @@ version: "1" runtime: typescript promptyVersion: 2.0.0 sessionId: sess_abc123 +events: + - id: evt_abc123 + type: session_start + timestamp: "2026-06-09T20:00:00Z" + sessionId: sess_abc123 + turnId: turn_001 + parentId: evt_parent + spanId: span_hook_001 ` var data map[string]interface{} @@ -77,6 +107,16 @@ sessionId: sess_abc123 if instance.SessionId == nil || *instance.SessionId != "sess_abc123" { t.Errorf(`Expected SessionId to be "sess_abc123", got %v`, instance.SessionId) } + if len(instance.Events) != 1 { + t.Fatalf("Expected Events length to be 1, got %d", len(instance.Events)) + } + assertSessionTraceStringField(t, instance.Events[0], "Id", "evt_abc123", "Events[0].Id") + assertSessionTraceStringField(t, instance.Events[0], "Type", "session_start", "Events[0].Type") + assertSessionTraceStringField(t, instance.Events[0], "Timestamp", "2026-06-09T20:00:00Z", "Events[0].Timestamp") + assertSessionTraceStringField(t, instance.Events[0], "SessionId", "sess_abc123", "Events[0].SessionId") + assertSessionTraceStringField(t, instance.Events[0], "TurnId", "turn_001", "Events[0].TurnId") + assertSessionTraceStringField(t, instance.Events[0], "ParentId", "evt_parent", "Events[0].ParentId") + assertSessionTraceStringField(t, instance.Events[0], "SpanId", "span_hook_001", "Events[0].SpanId") } // TestSessionTraceFromJSON tests loading SessionTrace through the generated JSON helper @@ -86,7 +126,18 @@ func TestSessionTraceFromJSON(t *testing.T) { "version": "1", "runtime": "typescript", "promptyVersion": "2.0.0", - "sessionId": "sess_abc123" + "sessionId": "sess_abc123", + "events": [ + { + "id": "evt_abc123", + "type": "session_start", + "timestamp": "2026-06-09T20:00:00Z", + "sessionId": "sess_abc123", + "turnId": "turn_001", + "parentId": "evt_parent", + "spanId": "span_hook_001" + } + ] } ` @@ -106,6 +157,16 @@ func TestSessionTraceFromJSON(t *testing.T) { if instance.SessionId == nil || *instance.SessionId != "sess_abc123" { t.Errorf(`Expected SessionId to be "sess_abc123", got %v`, instance.SessionId) } + if len(instance.Events) != 1 { + t.Fatalf("Expected Events length to be 1, got %d", len(instance.Events)) + } + assertSessionTraceStringField(t, instance.Events[0], "Id", "evt_abc123", "Events[0].Id") + assertSessionTraceStringField(t, instance.Events[0], "Type", "session_start", "Events[0].Type") + assertSessionTraceStringField(t, instance.Events[0], "Timestamp", "2026-06-09T20:00:00Z", "Events[0].Timestamp") + assertSessionTraceStringField(t, instance.Events[0], "SessionId", "sess_abc123", "Events[0].SessionId") + assertSessionTraceStringField(t, instance.Events[0], "TurnId", "turn_001", "Events[0].TurnId") + assertSessionTraceStringField(t, instance.Events[0], "ParentId", "evt_parent", "Events[0].ParentId") + assertSessionTraceStringField(t, instance.Events[0], "SpanId", "span_hook_001", "Events[0].SpanId") } // TestSessionTraceFromYAML tests loading SessionTrace through the generated YAML helper @@ -115,6 +176,14 @@ version: "1" runtime: typescript promptyVersion: 2.0.0 sessionId: sess_abc123 +events: + - id: evt_abc123 + type: session_start + timestamp: "2026-06-09T20:00:00Z" + sessionId: sess_abc123 + turnId: turn_001 + parentId: evt_parent + spanId: span_hook_001 ` @@ -134,6 +203,16 @@ sessionId: sess_abc123 if instance.SessionId == nil || *instance.SessionId != "sess_abc123" { t.Errorf(`Expected SessionId to be "sess_abc123", got %v`, instance.SessionId) } + if len(instance.Events) != 1 { + t.Fatalf("Expected Events length to be 1, got %d", len(instance.Events)) + } + assertSessionTraceStringField(t, instance.Events[0], "Id", "evt_abc123", "Events[0].Id") + assertSessionTraceStringField(t, instance.Events[0], "Type", "session_start", "Events[0].Type") + assertSessionTraceStringField(t, instance.Events[0], "Timestamp", "2026-06-09T20:00:00Z", "Events[0].Timestamp") + assertSessionTraceStringField(t, instance.Events[0], "SessionId", "sess_abc123", "Events[0].SessionId") + assertSessionTraceStringField(t, instance.Events[0], "TurnId", "turn_001", "Events[0].TurnId") + assertSessionTraceStringField(t, instance.Events[0], "ParentId", "evt_parent", "Events[0].ParentId") + assertSessionTraceStringField(t, instance.Events[0], "SpanId", "span_hook_001", "Events[0].SpanId") } // TestSessionTraceRoundtrip tests load -> save -> load produces equivalent data @@ -143,7 +222,18 @@ func TestSessionTraceRoundtrip(t *testing.T) { "version": "1", "runtime": "typescript", "promptyVersion": "2.0.0", - "sessionId": "sess_abc123" + "sessionId": "sess_abc123", + "events": [ + { + "id": "evt_abc123", + "type": "session_start", + "timestamp": "2026-06-09T20:00:00Z", + "sessionId": "sess_abc123", + "turnId": "turn_001", + "parentId": "evt_parent", + "spanId": "span_hook_001" + } + ] } ` var data map[string]interface{} @@ -175,6 +265,16 @@ func TestSessionTraceRoundtrip(t *testing.T) { if reloaded.SessionId == nil || *reloaded.SessionId != "sess_abc123" { t.Errorf(`Expected SessionId to be "sess_abc123", got %v`, reloaded.SessionId) } + if len(reloaded.Events) != 1 { + t.Fatalf("Expected Events length to be 1, got %d", len(reloaded.Events)) + } + assertSessionTraceStringField(t, reloaded.Events[0], "Id", "evt_abc123", "Events[0].Id") + assertSessionTraceStringField(t, reloaded.Events[0], "Type", "session_start", "Events[0].Type") + assertSessionTraceStringField(t, reloaded.Events[0], "Timestamp", "2026-06-09T20:00:00Z", "Events[0].Timestamp") + assertSessionTraceStringField(t, reloaded.Events[0], "SessionId", "sess_abc123", "Events[0].SessionId") + assertSessionTraceStringField(t, reloaded.Events[0], "TurnId", "turn_001", "Events[0].TurnId") + assertSessionTraceStringField(t, reloaded.Events[0], "ParentId", "evt_parent", "Events[0].ParentId") + assertSessionTraceStringField(t, reloaded.Events[0], "SpanId", "span_hook_001", "Events[0].SpanId") } // TestSessionTraceToJSON tests that ToJSON produces valid JSON @@ -184,7 +284,18 @@ func TestSessionTraceToJSON(t *testing.T) { "version": "1", "runtime": "typescript", "promptyVersion": "2.0.0", - "sessionId": "sess_abc123" + "sessionId": "sess_abc123", + "events": [ + { + "id": "evt_abc123", + "type": "session_start", + "timestamp": "2026-06-09T20:00:00Z", + "sessionId": "sess_abc123", + "turnId": "turn_001", + "parentId": "evt_parent", + "spanId": "span_hook_001" + } + ] } ` var data map[string]interface{} @@ -223,6 +334,16 @@ func TestSessionTraceToJSON(t *testing.T) { if reloaded.SessionId == nil || *reloaded.SessionId != "sess_abc123" { t.Errorf(`Expected SessionId to be "sess_abc123", got %v`, reloaded.SessionId) } + if len(reloaded.Events) != 1 { + t.Fatalf("Expected Events length to be 1, got %d", len(reloaded.Events)) + } + assertSessionTraceStringField(t, reloaded.Events[0], "Id", "evt_abc123", "Events[0].Id") + assertSessionTraceStringField(t, reloaded.Events[0], "Type", "session_start", "Events[0].Type") + assertSessionTraceStringField(t, reloaded.Events[0], "Timestamp", "2026-06-09T20:00:00Z", "Events[0].Timestamp") + assertSessionTraceStringField(t, reloaded.Events[0], "SessionId", "sess_abc123", "Events[0].SessionId") + assertSessionTraceStringField(t, reloaded.Events[0], "TurnId", "turn_001", "Events[0].TurnId") + assertSessionTraceStringField(t, reloaded.Events[0], "ParentId", "evt_parent", "Events[0].ParentId") + assertSessionTraceStringField(t, reloaded.Events[0], "SpanId", "span_hook_001", "Events[0].SpanId") } // TestSessionTraceToYAML tests that ToYAML produces valid YAML @@ -232,7 +353,18 @@ func TestSessionTraceToYAML(t *testing.T) { "version": "1", "runtime": "typescript", "promptyVersion": "2.0.0", - "sessionId": "sess_abc123" + "sessionId": "sess_abc123", + "events": [ + { + "id": "evt_abc123", + "type": "session_start", + "timestamp": "2026-06-09T20:00:00Z", + "sessionId": "sess_abc123", + "turnId": "turn_001", + "parentId": "evt_parent", + "spanId": "span_hook_001" + } + ] } ` var data map[string]interface{} @@ -271,6 +403,16 @@ func TestSessionTraceToYAML(t *testing.T) { if reloaded.SessionId == nil || *reloaded.SessionId != "sess_abc123" { t.Errorf(`Expected SessionId to be "sess_abc123", got %v`, reloaded.SessionId) } + if len(reloaded.Events) != 1 { + t.Fatalf("Expected Events length to be 1, got %d", len(reloaded.Events)) + } + assertSessionTraceStringField(t, reloaded.Events[0], "Id", "evt_abc123", "Events[0].Id") + assertSessionTraceStringField(t, reloaded.Events[0], "Type", "session_start", "Events[0].Type") + assertSessionTraceStringField(t, reloaded.Events[0], "Timestamp", "2026-06-09T20:00:00Z", "Events[0].Timestamp") + assertSessionTraceStringField(t, reloaded.Events[0], "SessionId", "sess_abc123", "Events[0].SessionId") + assertSessionTraceStringField(t, reloaded.Events[0], "TurnId", "turn_001", "Events[0].TurnId") + assertSessionTraceStringField(t, reloaded.Events[0], "ParentId", "evt_parent", "Events[0].ParentId") + assertSessionTraceStringField(t, reloaded.Events[0], "SpanId", "span_hook_001", "Events[0].SpanId") } // TestSessionTraceFromJSONInvalid rejects malformed JSON instead of silently defaulting @@ -279,3 +421,39 @@ func TestSessionTraceFromJSONInvalid(t *testing.T) { t.Fatalf("Expected malformed JSON to fail") } } + +func assertSessionTraceStringField(t *testing.T, value interface{}, fieldName string, expected string, displayName string) { + t.Helper() + field := reflect.ValueOf(value) + if field.Kind() == reflect.Pointer { + if field.IsNil() { + t.Fatalf("Expected %s to be populated", displayName) + } + field = field.Elem() + } + if field.Kind() != reflect.Struct { + t.Fatalf("Expected %s receiver to be a struct, got %T", displayName, value) + } + member := field.FieldByName(fieldName) + if !member.IsValid() { + t.Fatalf("Expected %s to have field %s, got %T", displayName, fieldName, value) + } + if member.Kind() == reflect.Pointer { + if member.IsNil() { + t.Fatalf("Expected %s to be populated", displayName) + } + member = member.Elem() + } + if member.Kind() == reflect.Interface { + if member.IsNil() { + t.Fatalf("Expected %s to be populated", displayName) + } + member = member.Elem() + } + if member.Kind() != reflect.String { + t.Fatalf("Expected %s to be a string field, got %s", displayName, member.Kind()) + } + if got := member.String(); got != expected { + t.Errorf("Expected %s to be %q, got %q", displayName, expected, got) + } +} diff --git a/runtime/go/prompty/model/session_warning_payload.go b/runtime/go/prompty/model/session_warning_payload.go index fd3680175..ddbedfae0 100644 --- a/runtime/go/prompty/model/session_warning_payload.go +++ b/runtime/go/prompty/model/session_warning_payload.go @@ -20,6 +20,9 @@ type SessionWarningPayload struct { // LoadSessionWarningPayload creates a SessionWarningPayload from a map[string]interface{} func LoadSessionWarningPayload(data interface{}, ctx *LoadContext) (SessionWarningPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := SessionWarningPayload{} // Load from map diff --git a/runtime/go/prompty/model/status_event_payload.go b/runtime/go/prompty/model/status_event_payload.go index 51b68a989..0a507ac60 100644 --- a/runtime/go/prompty/model/status_event_payload.go +++ b/runtime/go/prompty/model/status_event_payload.go @@ -18,6 +18,9 @@ type StatusEventPayload struct { // LoadStatusEventPayload creates a StatusEventPayload from a map[string]interface{} func LoadStatusEventPayload(data interface{}, ctx *LoadContext) (StatusEventPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := StatusEventPayload{} // Load from map diff --git a/runtime/go/prompty/model/stream_chunk.go b/runtime/go/prompty/model/stream_chunk.go index 6d8a215d5..956e66d73 100644 --- a/runtime/go/prompty/model/stream_chunk.go +++ b/runtime/go/prompty/model/stream_chunk.go @@ -21,8 +21,9 @@ type StreamChunk struct { // LoadStreamChunk creates a StreamChunk from a map[string]interface{} // Returns interface{} because this is a polymorphic base type that can resolve to different child types func LoadStreamChunk(data interface{}, ctx *LoadContext) (interface{}, error) { - result := StreamChunk{} - + if ctx == nil { + ctx = NewLoadContext() + } // Handle polymorphic types based on discriminator if m, ok := data.(map[string]interface{}); ok { if discriminator, ok := m["kind"]; ok { @@ -40,22 +41,14 @@ func LoadStreamChunk(data interface{}, ctx *LoadContext) (interface{}, error) { case "error": return LoadErrorChunk(data, ctx) default: - return nil, fmt.Errorf("unknown StreamChunk discriminator value: %s", discriminator) + return nil, fmt.Errorf("unknown StreamChunk discriminator field 'kind' value: %s", discriminator) } default: - return nil, fmt.Errorf("unknown StreamChunk discriminator value: %v", discriminator) + return nil, fmt.Errorf("unknown StreamChunk discriminator field 'kind' value: %v", discriminator) } } } return nil, fmt.Errorf("missing StreamChunk discriminator property: kind") - // Load from map - if m, ok := data.(map[string]interface{}); ok { - if val, ok := m["kind"]; ok && val != nil { - result.Kind = string(val.(string)) - } - } - - return result, nil } // Save serializes StreamChunk to map[string]interface{} @@ -115,6 +108,9 @@ type TextChunk struct { // LoadTextChunk creates a TextChunk from a map[string]interface{} func LoadTextChunk(data interface{}, ctx *LoadContext) (TextChunk, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := TextChunk{} // Load from map @@ -186,6 +182,9 @@ type ThinkingChunk struct { // LoadThinkingChunk creates a ThinkingChunk from a map[string]interface{} func LoadThinkingChunk(data interface{}, ctx *LoadContext) (ThinkingChunk, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ThinkingChunk{} // Load from map @@ -257,16 +256,22 @@ type ToolChunk struct { // LoadToolChunk creates a ToolChunk from a map[string]interface{} func LoadToolChunk(data interface{}, ctx *LoadContext) (ToolChunk, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ToolChunk{} // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["toolCall"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("toolCall").Path) + } if val, ok := m["kind"]; ok && val != nil { result.Kind = string(val.(string)) } if val, ok := m["toolCall"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadToolCall(m, ctx) + loaded, err := LoadToolCall(m, ctx.At("toolCall")) if err != nil { return result, err } @@ -335,16 +340,22 @@ type UsageChunk struct { // LoadUsageChunk creates a UsageChunk from a map[string]interface{} func LoadUsageChunk(data interface{}, ctx *LoadContext) (UsageChunk, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := UsageChunk{} // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["usage"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("usage").Path) + } if val, ok := m["kind"]; ok && val != nil { result.Kind = string(val.(string)) } if val, ok := m["usage"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadInvocationUsage(m, ctx) + loaded, err := LoadInvocationUsage(m, ctx.At("usage")) if err != nil { return result, err } @@ -413,6 +424,9 @@ type ErrorChunk struct { // LoadErrorChunk creates a ErrorChunk from a map[string]interface{} func LoadErrorChunk(data interface{}, ctx *LoadContext) (ErrorChunk, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ErrorChunk{} // Load from map diff --git a/runtime/go/prompty/model/stream_options.go b/runtime/go/prompty/model/stream_options.go index a874b0146..f6b8ca7a7 100644 --- a/runtime/go/prompty/model/stream_options.go +++ b/runtime/go/prompty/model/stream_options.go @@ -19,6 +19,9 @@ type StreamOptions struct { // LoadStreamOptions creates a StreamOptions from a map[string]interface{} func LoadStreamOptions(data interface{}, ctx *LoadContext) (StreamOptions, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := StreamOptions{} // Load from map diff --git a/runtime/go/prompty/model/subscription_info.go b/runtime/go/prompty/model/subscription_info.go index 37198a22a..8128b3aa8 100644 --- a/runtime/go/prompty/model/subscription_info.go +++ b/runtime/go/prompty/model/subscription_info.go @@ -20,6 +20,9 @@ type SubscriptionInfo struct { // LoadSubscriptionInfo creates a SubscriptionInfo from a map[string]interface{} func LoadSubscriptionInfo(data interface{}, ctx *LoadContext) (SubscriptionInfo, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := SubscriptionInfo{} // Load from map diff --git a/runtime/go/prompty/model/template.go b/runtime/go/prompty/model/template.go index 49ea7e74f..1717587e4 100644 --- a/runtime/go/prompty/model/template.go +++ b/runtime/go/prompty/model/template.go @@ -6,6 +6,7 @@ package prompty import ( "encoding/json" + "fmt" "gopkg.in/yaml.v3" ) @@ -26,19 +27,28 @@ type Template struct { // LoadTemplate creates a Template from a map[string]interface{} func LoadTemplate(data interface{}, ctx *LoadContext) (Template, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := Template{} // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["format"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("format").Path) + } + if requiredValue, exists := m["parser"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("parser").Path) + } if val, ok := m["format"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadFormatConfig(m, ctx) + loaded, err := LoadFormatConfig(m, ctx.At("format")) if err != nil { return result, err } result.Format = loaded } else { - loaded, err := LoadFormatConfig(val, ctx) + loaded, err := LoadFormatConfig(val, ctx.At("format")) if err != nil { return result, err } @@ -47,13 +57,13 @@ func LoadTemplate(data interface{}, ctx *LoadContext) (Template, error) { } if val, ok := m["parser"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadParserConfig(m, ctx) + loaded, err := LoadParserConfig(m, ctx.At("parser")) if err != nil { return result, err } result.Parser = loaded } else { - loaded, err := LoadParserConfig(val, ctx) + loaded, err := LoadParserConfig(val, ctx.At("parser")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/thinking_event_payload.go b/runtime/go/prompty/model/thinking_event_payload.go index 901854f56..fe218032c 100644 --- a/runtime/go/prompty/model/thinking_event_payload.go +++ b/runtime/go/prompty/model/thinking_event_payload.go @@ -18,6 +18,9 @@ type ThinkingEventPayload struct { // LoadThinkingEventPayload creates a ThinkingEventPayload from a map[string]interface{} func LoadThinkingEventPayload(data interface{}, ctx *LoadContext) (ThinkingEventPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ThinkingEventPayload{} // Load from map diff --git a/runtime/go/prompty/model/thread_marker.go b/runtime/go/prompty/model/thread_marker.go index 6d9b85da9..71d677e22 100644 --- a/runtime/go/prompty/model/thread_marker.go +++ b/runtime/go/prompty/model/thread_marker.go @@ -23,6 +23,9 @@ type ThreadMarker struct { // LoadThreadMarker creates a ThreadMarker from a map[string]interface{} func LoadThreadMarker(data interface{}, ctx *LoadContext) (ThreadMarker, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ThreadMarker{} // Load from map diff --git a/runtime/go/prompty/model/token_event_payload.go b/runtime/go/prompty/model/token_event_payload.go index 172f300bf..e706512e8 100644 --- a/runtime/go/prompty/model/token_event_payload.go +++ b/runtime/go/prompty/model/token_event_payload.go @@ -18,6 +18,9 @@ type TokenEventPayload struct { // LoadTokenEventPayload creates a TokenEventPayload from a map[string]interface{} func LoadTokenEventPayload(data interface{}, ctx *LoadContext) (TokenEventPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := TokenEventPayload{} // Load from map diff --git a/runtime/go/prompty/model/token_usage.go b/runtime/go/prompty/model/token_usage.go index 195468ce0..f78a8d998 100644 --- a/runtime/go/prompty/model/token_usage.go +++ b/runtime/go/prompty/model/token_usage.go @@ -22,6 +22,9 @@ type TokenUsage struct { // LoadTokenUsage creates a TokenUsage from a map[string]interface{} func LoadTokenUsage(data interface{}, ctx *LoadContext) (TokenUsage, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := TokenUsage{} // Load from map diff --git a/runtime/go/prompty/model/tool.go b/runtime/go/prompty/model/tool.go index 6bf448d9f..6f6c01442 100644 --- a/runtime/go/prompty/model/tool.go +++ b/runtime/go/prompty/model/tool.go @@ -6,6 +6,9 @@ package prompty import ( "encoding/json" + "fmt" + "math" + "sort" "gopkg.in/yaml.v3" ) @@ -15,14 +18,19 @@ import ( type Tool struct { Name string `json:"name" yaml:"name"` Kind string `json:"kind" yaml:"kind"` - Description *string `json:"description,omitempty" yaml:"description,omitempty"` - Bindings []Binding `json:"bindings,omitempty" yaml:"bindings,omitempty"` + Description *string `json:"description" yaml:"description"` + Bindings []Binding `json:"bindings" yaml:"bindings"` } // LoadTool creates a Tool from a map[string]interface{} // Returns interface{} because this is a polymorphic base type that can resolve to different child types func LoadTool(data interface{}, ctx *LoadContext) (interface{}, error) { - result := Tool{} + if ctx == nil { + ctx = NewLoadContext() + } + result := Tool{ + Bindings: []Binding{}, + } // Handle polymorphic types based on discriminator if m, ok := data.(map[string]interface{}); ok { @@ -59,11 +67,41 @@ func LoadTool(data interface{}, ctx *LoadContext) (interface{}, error) { result.Description = &v } if val, ok := m["bindings"]; ok && val != nil { - if arr, ok := val.([]interface{}); ok { + if named, ok := val.(map[string]interface{}); ok { + keys := make([]string, 0, len(named)) + for key := range named { + keys = append(keys, key) + } + sort.Strings(keys) + result.Bindings = make([]Binding, 0, len(keys)) + for _, key := range keys { + entry := named[key] + if _, invalid := entry.([]interface{}); invalid { + return result, fmt.Errorf("%s: invalid named collection entry category array", ctx.At("bindings").At(key).Path) + } + item, ok := entry.(map[string]interface{}) + if !ok { + item = map[string]interface{}{} + item["input"] = entry + } else { + copy := make(map[string]interface{}, len(item)+1) + for itemKey, itemValue := range item { + copy[itemKey] = itemValue + } + item = copy + } + item["name"] = key + loaded, err := LoadBinding(item, ctx.At("bindings").At(key)) + if err != nil { + return result, err + } + result.Bindings = append(result.Bindings, loaded) + } + } else if arr, ok := val.([]interface{}); ok { result.Bindings = make([]Binding, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadBinding(item, ctx) + loaded, err := LoadBinding(item, ctx.At("bindings").AtIndex(i)) if err != nil { return result, err } @@ -90,7 +128,48 @@ func (obj Tool) Save(ctx *SaveContext) map[string]interface{} { for i, item := range obj.Bindings { arr[i] = item.Save(ctx) } - result["bindings"] = arr + seenNames := make(map[string]struct{}, len(arr)) + objectItems := make(map[string]interface{}, len(arr)) + losslessObject := true + for i, serialized := range arr { + item, ok := serialized.(map[string]interface{}) + if !ok { + losslessObject = false + continue + } + copy := make(map[string]interface{}, len(item)) + for key, value := range item { + copy[key] = value + } + name, hasName := copy["name"].(string) + if hasName && name == "" { + delete(copy, "name") + arr[i] = copy + hasName = false + } + if !hasName || name == "" { + losslessObject = false + continue + } + if _, duplicate := seenNames[name]; duplicate { + losslessObject = false + continue + } + seenNames[name] = struct{}{} + delete(copy, "name") + if (ctx == nil || ctx.UseShorthand) && len(copy) == 1 { + if shorthand, ok := copy["input"]; ok { + objectItems[name] = shorthand + continue + } + } + objectItems[name] = copy + } + if losslessObject && (ctx == nil || ctx.CollectionFormat != CollectionFormatArray) { + result["bindings"] = objectItems + } else { + result["bindings"] = arr + } } return result @@ -141,15 +220,20 @@ func ToolFromYAML(yamlStr string) (interface{}, error) { type FunctionTool struct { Name string `json:"name" yaml:"name"` Kind string `json:"kind" yaml:"kind"` - Description *string `json:"description,omitempty" yaml:"description,omitempty"` - Bindings []Binding `json:"bindings,omitempty" yaml:"bindings,omitempty"` + Description *string `json:"description" yaml:"description"` + Bindings []Binding `json:"bindings" yaml:"bindings"` Parameters []interface{} `json:"parameters" yaml:"parameters"` - Strict *bool `json:"strict,omitempty" yaml:"strict,omitempty"` + Strict *bool `json:"strict" yaml:"strict"` } // LoadFunctionTool creates a FunctionTool from a map[string]interface{} func LoadFunctionTool(data interface{}, ctx *LoadContext) (FunctionTool, error) { - result := FunctionTool{} + if ctx == nil { + ctx = NewLoadContext() + } + result := FunctionTool{ + Bindings: []Binding{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -164,11 +248,41 @@ func LoadFunctionTool(data interface{}, ctx *LoadContext) (FunctionTool, error) result.Description = &v } if val, ok := m["bindings"]; ok && val != nil { - if arr, ok := val.([]interface{}); ok { + if named, ok := val.(map[string]interface{}); ok { + keys := make([]string, 0, len(named)) + for key := range named { + keys = append(keys, key) + } + sort.Strings(keys) + result.Bindings = make([]Binding, 0, len(keys)) + for _, key := range keys { + entry := named[key] + if _, invalid := entry.([]interface{}); invalid { + return result, fmt.Errorf("%s: invalid named collection entry category array", ctx.At("bindings").At(key).Path) + } + item, ok := entry.(map[string]interface{}) + if !ok { + item = map[string]interface{}{} + item["input"] = entry + } else { + copy := make(map[string]interface{}, len(item)+1) + for itemKey, itemValue := range item { + copy[itemKey] = itemValue + } + item = copy + } + item["name"] = key + loaded, err := LoadBinding(item, ctx.At("bindings").At(key)) + if err != nil { + return result, err + } + result.Bindings = append(result.Bindings, loaded) + } + } else if arr, ok := val.([]interface{}); ok { result.Bindings = make([]Binding, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadBinding(item, ctx) + loaded, err := LoadBinding(item, ctx.At("bindings").AtIndex(i)) if err != nil { return result, err } @@ -178,11 +292,60 @@ func LoadFunctionTool(data interface{}, ctx *LoadContext) (FunctionTool, error) } } if val, ok := m["parameters"]; ok && val != nil { - if arr, ok := val.([]interface{}); ok { + if named, ok := val.(map[string]interface{}); ok { + keys := make([]string, 0, len(named)) + for key := range named { + keys = append(keys, key) + } + sort.Strings(keys) + result.Parameters = make([]interface{}, 0, len(keys)) + for _, key := range keys { + entry := named[key] + if _, invalid := entry.([]interface{}); invalid { + return result, fmt.Errorf("%s: invalid named collection entry category array", ctx.At("parameters").At(key).Path) + } + item, ok := entry.(map[string]interface{}) + if !ok { + item = map[string]interface{}{} + switch shorthandValue := entry.(type) { + case int, int32, int64: + item["kind"] = "integer" + item["default"] = shorthandValue + case float64: + if shorthandValue == math.Trunc(shorthandValue) { + item["kind"] = "integer" + } else { + item["kind"] = "float" + } + item["default"] = shorthandValue + case string: + item["kind"] = "string" + item["default"] = shorthandValue + case bool: + item["kind"] = "boolean" + item["default"] = shorthandValue + default: + item["default"] = shorthandValue + } + } else { + copy := make(map[string]interface{}, len(item)+1) + for itemKey, itemValue := range item { + copy[itemKey] = itemValue + } + item = copy + } + item["name"] = key + loaded, err := LoadProperty(item, ctx.At("parameters").At(key)) + if err != nil { + return result, err + } + result.Parameters = append(result.Parameters, loaded) + } + } else if arr, ok := val.([]interface{}); ok { result.Parameters = make([]interface{}, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadProperty(item, ctx) + loaded, err := LoadProperty(item, ctx.At("parameters").AtIndex(i)) if err != nil { return result, err } @@ -214,7 +377,48 @@ func (obj FunctionTool) Save(ctx *SaveContext) map[string]interface{} { for i, item := range obj.Bindings { arr[i] = item.Save(ctx) } - result["bindings"] = arr + seenNames := make(map[string]struct{}, len(arr)) + objectItems := make(map[string]interface{}, len(arr)) + losslessObject := true + for i, serialized := range arr { + item, ok := serialized.(map[string]interface{}) + if !ok { + losslessObject = false + continue + } + copy := make(map[string]interface{}, len(item)) + for key, value := range item { + copy[key] = value + } + name, hasName := copy["name"].(string) + if hasName && name == "" { + delete(copy, "name") + arr[i] = copy + hasName = false + } + if !hasName || name == "" { + losslessObject = false + continue + } + if _, duplicate := seenNames[name]; duplicate { + losslessObject = false + continue + } + seenNames[name] = struct{}{} + delete(copy, "name") + if (ctx == nil || ctx.UseShorthand) && len(copy) == 1 { + if shorthand, ok := copy["input"]; ok { + objectItems[name] = shorthand + continue + } + } + objectItems[name] = copy + } + if losslessObject && (ctx == nil || ctx.CollectionFormat != CollectionFormatArray) { + result["bindings"] = objectItems + } else { + result["bindings"] = arr + } } if obj.Parameters != nil { arr := make([]interface{}, len(obj.Parameters)) @@ -229,7 +433,48 @@ func (obj FunctionTool) Save(ctx *SaveContext) map[string]interface{} { arr[i] = item } } - result["parameters"] = arr + seenNames := make(map[string]struct{}, len(arr)) + objectItems := make(map[string]interface{}, len(arr)) + losslessObject := true + for i, serialized := range arr { + item, ok := serialized.(map[string]interface{}) + if !ok { + losslessObject = false + continue + } + copy := make(map[string]interface{}, len(item)) + for key, value := range item { + copy[key] = value + } + name, hasName := copy["name"].(string) + if hasName && name == "" { + delete(copy, "name") + arr[i] = copy + hasName = false + } + if !hasName || name == "" { + losslessObject = false + continue + } + if _, duplicate := seenNames[name]; duplicate { + losslessObject = false + continue + } + seenNames[name] = struct{}{} + delete(copy, "name") + if (ctx == nil || ctx.UseShorthand) && len(copy) == 1 { + if shorthand, ok := copy["example"]; ok { + objectItems[name] = shorthand + continue + } + } + objectItems[name] = copy + } + if losslessObject && (ctx == nil || ctx.CollectionFormat != CollectionFormatArray) { + result["parameters"] = objectItems + } else { + result["parameters"] = arr + } } if obj.Strict != nil { result["strict"] = *obj.Strict @@ -285,18 +530,28 @@ func FunctionToolFromYAML(yamlStr string) (FunctionTool, error) { type CustomTool struct { Name string `json:"name" yaml:"name"` Kind string `json:"kind" yaml:"kind"` - Description *string `json:"description,omitempty" yaml:"description,omitempty"` - Bindings []Binding `json:"bindings,omitempty" yaml:"bindings,omitempty"` + Description *string `json:"description" yaml:"description"` + Bindings []Binding `json:"bindings" yaml:"bindings"` Connection interface{} `json:"connection" yaml:"connection"` Options map[string]interface{} `json:"options" yaml:"options"` } // LoadCustomTool creates a CustomTool from a map[string]interface{} func LoadCustomTool(data interface{}, ctx *LoadContext) (CustomTool, error) { - result := CustomTool{} + if ctx == nil { + ctx = NewLoadContext() + } + result := CustomTool{ + Bindings: []Binding{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { + if discriminatorValue, hasDiscriminator := m["kind"].(string); hasDiscriminator && discriminatorValue != "" { + if requiredValue, exists := m["connection"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("connection").Path) + } + } if val, ok := m["name"]; ok && val != nil { result.Name = string(val.(string)) } @@ -308,11 +563,41 @@ func LoadCustomTool(data interface{}, ctx *LoadContext) (CustomTool, error) { result.Description = &v } if val, ok := m["bindings"]; ok && val != nil { - if arr, ok := val.([]interface{}); ok { + if named, ok := val.(map[string]interface{}); ok { + keys := make([]string, 0, len(named)) + for key := range named { + keys = append(keys, key) + } + sort.Strings(keys) + result.Bindings = make([]Binding, 0, len(keys)) + for _, key := range keys { + entry := named[key] + if _, invalid := entry.([]interface{}); invalid { + return result, fmt.Errorf("%s: invalid named collection entry category array", ctx.At("bindings").At(key).Path) + } + item, ok := entry.(map[string]interface{}) + if !ok { + item = map[string]interface{}{} + item["input"] = entry + } else { + copy := make(map[string]interface{}, len(item)+1) + for itemKey, itemValue := range item { + copy[itemKey] = itemValue + } + item = copy + } + item["name"] = key + loaded, err := LoadBinding(item, ctx.At("bindings").At(key)) + if err != nil { + return result, err + } + result.Bindings = append(result.Bindings, loaded) + } + } else if arr, ok := val.([]interface{}); ok { result.Bindings = make([]Binding, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadBinding(item, ctx) + loaded, err := LoadBinding(item, ctx.At("bindings").AtIndex(i)) if err != nil { return result, err } @@ -323,7 +608,7 @@ func LoadCustomTool(data interface{}, ctx *LoadContext) (CustomTool, error) { } if val, ok := m["connection"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadConnection(m, ctx) + loaded, err := LoadConnection(m, ctx.At("connection")) if err != nil { return result, err } @@ -354,7 +639,48 @@ func (obj CustomTool) Save(ctx *SaveContext) map[string]interface{} { for i, item := range obj.Bindings { arr[i] = item.Save(ctx) } - result["bindings"] = arr + seenNames := make(map[string]struct{}, len(arr)) + objectItems := make(map[string]interface{}, len(arr)) + losslessObject := true + for i, serialized := range arr { + item, ok := serialized.(map[string]interface{}) + if !ok { + losslessObject = false + continue + } + copy := make(map[string]interface{}, len(item)) + for key, value := range item { + copy[key] = value + } + name, hasName := copy["name"].(string) + if hasName && name == "" { + delete(copy, "name") + arr[i] = copy + hasName = false + } + if !hasName || name == "" { + losslessObject = false + continue + } + if _, duplicate := seenNames[name]; duplicate { + losslessObject = false + continue + } + seenNames[name] = struct{}{} + delete(copy, "name") + if (ctx == nil || ctx.UseShorthand) && len(copy) == 1 { + if shorthand, ok := copy["input"]; ok { + objectItems[name] = shorthand + continue + } + } + objectItems[name] = copy + } + if losslessObject && (ctx == nil || ctx.CollectionFormat != CollectionFormatArray) { + result["bindings"] = objectItems + } else { + result["bindings"] = arr + } } // Handle polymorphic type via type switch @@ -412,23 +738,31 @@ func CustomToolFromYAML(yamlStr string) (CustomTool, error) { // McpTool represents The MCP Server tool. type McpTool struct { - Name string `json:"name" yaml:"name"` - Kind string `json:"kind" yaml:"kind"` - Description *string `json:"description,omitempty" yaml:"description,omitempty"` - Bindings []Binding `json:"bindings,omitempty" yaml:"bindings,omitempty"` - Connection interface{} `json:"connection" yaml:"connection"` - ServerName string `json:"serverName" yaml:"serverName"` - ServerDescription *string `json:"serverDescription,omitempty" yaml:"serverDescription,omitempty"` - ApprovalMode McpApprovalMode `json:"approvalMode" yaml:"approvalMode"` - AllowedTools []string `json:"allowedTools,omitempty" yaml:"allowedTools,omitempty"` + Name string `json:"name" yaml:"name"` + Kind string `json:"kind" yaml:"kind"` + Description *string `json:"description" yaml:"description"` + Bindings []Binding `json:"bindings" yaml:"bindings"` + Connection interface{} `json:"connection" yaml:"connection"` + ServerName string `json:"serverName" yaml:"serverName"` + ServerDescription *string `json:"serverDescription,omitempty" yaml:"serverDescription,omitempty"` + ApprovalMode *McpApprovalMode `json:"approvalMode,omitempty" yaml:"approvalMode,omitempty"` + AllowedTools []string `json:"allowedTools,omitempty" yaml:"allowedTools,omitempty"` } // LoadMcpTool creates a McpTool from a map[string]interface{} func LoadMcpTool(data interface{}, ctx *LoadContext) (McpTool, error) { - result := McpTool{} + if ctx == nil { + ctx = NewLoadContext() + } + result := McpTool{ + Bindings: []Binding{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["connection"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("connection").Path) + } if val, ok := m["name"]; ok && val != nil { result.Name = string(val.(string)) } @@ -440,11 +774,41 @@ func LoadMcpTool(data interface{}, ctx *LoadContext) (McpTool, error) { result.Description = &v } if val, ok := m["bindings"]; ok && val != nil { - if arr, ok := val.([]interface{}); ok { + if named, ok := val.(map[string]interface{}); ok { + keys := make([]string, 0, len(named)) + for key := range named { + keys = append(keys, key) + } + sort.Strings(keys) + result.Bindings = make([]Binding, 0, len(keys)) + for _, key := range keys { + entry := named[key] + if _, invalid := entry.([]interface{}); invalid { + return result, fmt.Errorf("%s: invalid named collection entry category array", ctx.At("bindings").At(key).Path) + } + item, ok := entry.(map[string]interface{}) + if !ok { + item = map[string]interface{}{} + item["input"] = entry + } else { + copy := make(map[string]interface{}, len(item)+1) + for itemKey, itemValue := range item { + copy[itemKey] = itemValue + } + item = copy + } + item["name"] = key + loaded, err := LoadBinding(item, ctx.At("bindings").At(key)) + if err != nil { + return result, err + } + result.Bindings = append(result.Bindings, loaded) + } + } else if arr, ok := val.([]interface{}); ok { result.Bindings = make([]Binding, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadBinding(item, ctx) + loaded, err := LoadBinding(item, ctx.At("bindings").AtIndex(i)) if err != nil { return result, err } @@ -455,7 +819,7 @@ func LoadMcpTool(data interface{}, ctx *LoadContext) (McpTool, error) { } if val, ok := m["connection"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadConnection(m, ctx) + loaded, err := LoadConnection(m, ctx.At("connection")) if err != nil { return result, err } @@ -472,17 +836,17 @@ func LoadMcpTool(data interface{}, ctx *LoadContext) (McpTool, error) { } if val, ok := m["approvalMode"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadMcpApprovalMode(m, ctx) + loaded, err := LoadMcpApprovalMode(m, ctx.At("approvalMode")) if err != nil { return result, err } - result.ApprovalMode = loaded + result.ApprovalMode = &loaded } else { - loaded, err := LoadMcpApprovalMode(val, ctx) + loaded, err := LoadMcpApprovalMode(val, ctx.At("approvalMode")) if err != nil { return result, err } - result.ApprovalMode = loaded + result.ApprovalMode = &loaded } } if val, ok := m["allowedTools"]; ok && val != nil { @@ -514,7 +878,48 @@ func (obj McpTool) Save(ctx *SaveContext) map[string]interface{} { for i, item := range obj.Bindings { arr[i] = item.Save(ctx) } - result["bindings"] = arr + seenNames := make(map[string]struct{}, len(arr)) + objectItems := make(map[string]interface{}, len(arr)) + losslessObject := true + for i, serialized := range arr { + item, ok := serialized.(map[string]interface{}) + if !ok { + losslessObject = false + continue + } + copy := make(map[string]interface{}, len(item)) + for key, value := range item { + copy[key] = value + } + name, hasName := copy["name"].(string) + if hasName && name == "" { + delete(copy, "name") + arr[i] = copy + hasName = false + } + if !hasName || name == "" { + losslessObject = false + continue + } + if _, duplicate := seenNames[name]; duplicate { + losslessObject = false + continue + } + seenNames[name] = struct{}{} + delete(copy, "name") + if (ctx == nil || ctx.UseShorthand) && len(copy) == 1 { + if shorthand, ok := copy["input"]; ok { + objectItems[name] = shorthand + continue + } + } + objectItems[name] = copy + } + if losslessObject && (ctx == nil || ctx.CollectionFormat != CollectionFormatArray) { + result["bindings"] = objectItems + } else { + result["bindings"] = arr + } } // Handle polymorphic type via type switch @@ -530,9 +935,12 @@ func (obj McpTool) Save(ctx *SaveContext) map[string]interface{} { if obj.ServerDescription != nil { result["serverDescription"] = *obj.ServerDescription } - - result["approvalMode"] = obj.ApprovalMode.Save(ctx) - result["allowedTools"] = obj.AllowedTools + if obj.ApprovalMode != nil { + result["approvalMode"] = obj.ApprovalMode.Save(ctx) + } + if obj.AllowedTools != nil { + result["allowedTools"] = obj.AllowedTools + } return result } @@ -579,18 +987,26 @@ func McpToolFromYAML(yamlStr string) (McpTool, error) { type OpenApiTool struct { Name string `json:"name" yaml:"name"` Kind string `json:"kind" yaml:"kind"` - Description *string `json:"description,omitempty" yaml:"description,omitempty"` - Bindings []Binding `json:"bindings,omitempty" yaml:"bindings,omitempty"` + Description *string `json:"description" yaml:"description"` + Bindings []Binding `json:"bindings" yaml:"bindings"` Connection interface{} `json:"connection" yaml:"connection"` Specification string `json:"specification" yaml:"specification"` } // LoadOpenApiTool creates a OpenApiTool from a map[string]interface{} func LoadOpenApiTool(data interface{}, ctx *LoadContext) (OpenApiTool, error) { - result := OpenApiTool{} + if ctx == nil { + ctx = NewLoadContext() + } + result := OpenApiTool{ + Bindings: []Binding{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["connection"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("connection").Path) + } if val, ok := m["name"]; ok && val != nil { result.Name = string(val.(string)) } @@ -602,11 +1018,41 @@ func LoadOpenApiTool(data interface{}, ctx *LoadContext) (OpenApiTool, error) { result.Description = &v } if val, ok := m["bindings"]; ok && val != nil { - if arr, ok := val.([]interface{}); ok { + if named, ok := val.(map[string]interface{}); ok { + keys := make([]string, 0, len(named)) + for key := range named { + keys = append(keys, key) + } + sort.Strings(keys) + result.Bindings = make([]Binding, 0, len(keys)) + for _, key := range keys { + entry := named[key] + if _, invalid := entry.([]interface{}); invalid { + return result, fmt.Errorf("%s: invalid named collection entry category array", ctx.At("bindings").At(key).Path) + } + item, ok := entry.(map[string]interface{}) + if !ok { + item = map[string]interface{}{} + item["input"] = entry + } else { + copy := make(map[string]interface{}, len(item)+1) + for itemKey, itemValue := range item { + copy[itemKey] = itemValue + } + item = copy + } + item["name"] = key + loaded, err := LoadBinding(item, ctx.At("bindings").At(key)) + if err != nil { + return result, err + } + result.Bindings = append(result.Bindings, loaded) + } + } else if arr, ok := val.([]interface{}); ok { result.Bindings = make([]Binding, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadBinding(item, ctx) + loaded, err := LoadBinding(item, ctx.At("bindings").AtIndex(i)) if err != nil { return result, err } @@ -617,7 +1063,7 @@ func LoadOpenApiTool(data interface{}, ctx *LoadContext) (OpenApiTool, error) { } if val, ok := m["connection"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadConnection(m, ctx) + loaded, err := LoadConnection(m, ctx.At("connection")) if err != nil { return result, err } @@ -646,7 +1092,48 @@ func (obj OpenApiTool) Save(ctx *SaveContext) map[string]interface{} { for i, item := range obj.Bindings { arr[i] = item.Save(ctx) } - result["bindings"] = arr + seenNames := make(map[string]struct{}, len(arr)) + objectItems := make(map[string]interface{}, len(arr)) + losslessObject := true + for i, serialized := range arr { + item, ok := serialized.(map[string]interface{}) + if !ok { + losslessObject = false + continue + } + copy := make(map[string]interface{}, len(item)) + for key, value := range item { + copy[key] = value + } + name, hasName := copy["name"].(string) + if hasName && name == "" { + delete(copy, "name") + arr[i] = copy + hasName = false + } + if !hasName || name == "" { + losslessObject = false + continue + } + if _, duplicate := seenNames[name]; duplicate { + losslessObject = false + continue + } + seenNames[name] = struct{}{} + delete(copy, "name") + if (ctx == nil || ctx.UseShorthand) && len(copy) == 1 { + if shorthand, ok := copy["input"]; ok { + objectItems[name] = shorthand + continue + } + } + objectItems[name] = copy + } + if losslessObject && (ctx == nil || ctx.CollectionFormat != CollectionFormatArray) { + result["bindings"] = objectItems + } else { + result["bindings"] = arr + } } // Handle polymorphic type via type switch @@ -709,15 +1196,20 @@ func OpenApiToolFromYAML(yamlStr string) (OpenApiTool, error) { type PromptyTool struct { Name string `json:"name" yaml:"name"` Kind string `json:"kind" yaml:"kind"` - Description *string `json:"description,omitempty" yaml:"description,omitempty"` - Bindings []Binding `json:"bindings,omitempty" yaml:"bindings,omitempty"` + Description *string `json:"description" yaml:"description"` + Bindings []Binding `json:"bindings" yaml:"bindings"` Path string `json:"path" yaml:"path"` Mode string `json:"mode" yaml:"mode"` } // LoadPromptyTool creates a PromptyTool from a map[string]interface{} func LoadPromptyTool(data interface{}, ctx *LoadContext) (PromptyTool, error) { - result := PromptyTool{} + if ctx == nil { + ctx = NewLoadContext() + } + result := PromptyTool{ + Bindings: []Binding{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -732,11 +1224,41 @@ func LoadPromptyTool(data interface{}, ctx *LoadContext) (PromptyTool, error) { result.Description = &v } if val, ok := m["bindings"]; ok && val != nil { - if arr, ok := val.([]interface{}); ok { + if named, ok := val.(map[string]interface{}); ok { + keys := make([]string, 0, len(named)) + for key := range named { + keys = append(keys, key) + } + sort.Strings(keys) + result.Bindings = make([]Binding, 0, len(keys)) + for _, key := range keys { + entry := named[key] + if _, invalid := entry.([]interface{}); invalid { + return result, fmt.Errorf("%s: invalid named collection entry category array", ctx.At("bindings").At(key).Path) + } + item, ok := entry.(map[string]interface{}) + if !ok { + item = map[string]interface{}{} + item["input"] = entry + } else { + copy := make(map[string]interface{}, len(item)+1) + for itemKey, itemValue := range item { + copy[itemKey] = itemValue + } + item = copy + } + item["name"] = key + loaded, err := LoadBinding(item, ctx.At("bindings").At(key)) + if err != nil { + return result, err + } + result.Bindings = append(result.Bindings, loaded) + } + } else if arr, ok := val.([]interface{}); ok { result.Bindings = make([]Binding, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadBinding(item, ctx) + loaded, err := LoadBinding(item, ctx.At("bindings").AtIndex(i)) if err != nil { return result, err } @@ -769,7 +1291,48 @@ func (obj PromptyTool) Save(ctx *SaveContext) map[string]interface{} { for i, item := range obj.Bindings { arr[i] = item.Save(ctx) } - result["bindings"] = arr + seenNames := make(map[string]struct{}, len(arr)) + objectItems := make(map[string]interface{}, len(arr)) + losslessObject := true + for i, serialized := range arr { + item, ok := serialized.(map[string]interface{}) + if !ok { + losslessObject = false + continue + } + copy := make(map[string]interface{}, len(item)) + for key, value := range item { + copy[key] = value + } + name, hasName := copy["name"].(string) + if hasName && name == "" { + delete(copy, "name") + arr[i] = copy + hasName = false + } + if !hasName || name == "" { + losslessObject = false + continue + } + if _, duplicate := seenNames[name]; duplicate { + losslessObject = false + continue + } + seenNames[name] = struct{}{} + delete(copy, "name") + if (ctx == nil || ctx.UseShorthand) && len(copy) == 1 { + if shorthand, ok := copy["input"]; ok { + objectItems[name] = shorthand + continue + } + } + objectItems[name] = copy + } + if losslessObject && (ctx == nil || ctx.CollectionFormat != CollectionFormatArray) { + result["bindings"] = objectItems + } else { + result["bindings"] = arr + } } result["path"] = obj.Path result["mode"] = obj.Mode diff --git a/runtime/go/prompty/model/tool_call.go b/runtime/go/prompty/model/tool_call.go index e9100ae13..9bb4918c0 100644 --- a/runtime/go/prompty/model/tool_call.go +++ b/runtime/go/prompty/model/tool_call.go @@ -21,6 +21,9 @@ type ToolCall struct { // LoadToolCall creates a ToolCall from a map[string]interface{} func LoadToolCall(data interface{}, ctx *LoadContext) (ToolCall, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ToolCall{} // Load from map diff --git a/runtime/go/prompty/model/tool_call_complete_payload.go b/runtime/go/prompty/model/tool_call_complete_payload.go index ac8537496..3b497813b 100644 --- a/runtime/go/prompty/model/tool_call_complete_payload.go +++ b/runtime/go/prompty/model/tool_call_complete_payload.go @@ -23,6 +23,9 @@ type ToolCallCompletePayload struct { // LoadToolCallCompletePayload creates a ToolCallCompletePayload from a map[string]interface{} func LoadToolCallCompletePayload(data interface{}, ctx *LoadContext) (ToolCallCompletePayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ToolCallCompletePayload{} // Load from map @@ -39,7 +42,7 @@ func LoadToolCallCompletePayload(data interface{}, ctx *LoadContext) (ToolCallCo } if val, ok := m["result"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadToolResult(m, ctx) + loaded, err := LoadToolResult(m, ctx.At("result")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/tool_call_start_payload.go b/runtime/go/prompty/model/tool_call_start_payload.go index 87e23f1f4..209020540 100644 --- a/runtime/go/prompty/model/tool_call_start_payload.go +++ b/runtime/go/prompty/model/tool_call_start_payload.go @@ -20,6 +20,9 @@ type ToolCallStartPayload struct { // LoadToolCallStartPayload creates a ToolCallStartPayload from a map[string]interface{} func LoadToolCallStartPayload(data interface{}, ctx *LoadContext) (ToolCallStartPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ToolCallStartPayload{} // Load from map diff --git a/runtime/go/prompty/model/tool_context.go b/runtime/go/prompty/model/tool_context.go index e6bcb1df9..897b84597 100644 --- a/runtime/go/prompty/model/tool_context.go +++ b/runtime/go/prompty/model/tool_context.go @@ -21,6 +21,9 @@ type ToolContext struct { // LoadToolContext creates a ToolContext from a map[string]interface{} func LoadToolContext(data interface{}, ctx *LoadContext) (ToolContext, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ToolContext{} // Load from map @@ -30,7 +33,7 @@ func LoadToolContext(data interface{}, ctx *LoadContext) (ToolContext, error) { result.Messages = make([]Message, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadMessage(item, ctx) + loaded, err := LoadMessage(item, ctx.At("messages").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/tool_context_test.go b/runtime/go/prompty/model/tool_context_test.go index b106068aa..56bdb80be 100644 --- a/runtime/go/prompty/model/tool_context_test.go +++ b/runtime/go/prompty/model/tool_context_test.go @@ -5,6 +5,7 @@ package prompty_test import ( "encoding/json" + "reflect" "testing" "gopkg.in/yaml.v3" @@ -18,7 +19,21 @@ func TestToolContextLoadJSON(t *testing.T) { { "metadata": { "userId": "user-123" - } + }, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "value": "Hello!" + } + ], + "metadata": { + "source": "user-input" + } + } + ] } ` var data map[string]interface{} @@ -32,6 +47,10 @@ func TestToolContextLoadJSON(t *testing.T) { t.Fatalf("Failed to load ToolContext: %v", err) } _ = instance // No scalar properties to validate + if len(instance.Messages) != 1 { + t.Fatalf("Expected Messages length to be 1, got %d", len(instance.Messages)) + } + assertToolContextStringField(t, instance.Messages[0], "Role", "user", "Messages[0].Role") if instance.Metadata == nil { t.Fatalf("Expected Metadata to be populated") } @@ -45,6 +64,13 @@ func TestToolContextLoadYAML(t *testing.T) { yamlData := ` metadata: userId: user-123 +messages: + - role: user + parts: + - kind: text + value: Hello! + metadata: + source: user-input ` var data map[string]interface{} @@ -58,6 +84,10 @@ metadata: t.Fatalf("Failed to load ToolContext: %v", err) } _ = instance // No scalar properties to validate + if len(instance.Messages) != 1 { + t.Fatalf("Expected Messages length to be 1, got %d", len(instance.Messages)) + } + assertToolContextStringField(t, instance.Messages[0], "Role", "user", "Messages[0].Role") if instance.Metadata == nil { t.Fatalf("Expected Metadata to be populated") } @@ -72,7 +102,21 @@ func TestToolContextFromJSON(t *testing.T) { { "metadata": { "userId": "user-123" - } + }, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "value": "Hello!" + } + ], + "metadata": { + "source": "user-input" + } + } + ] } ` @@ -81,6 +125,10 @@ func TestToolContextFromJSON(t *testing.T) { t.Fatalf("Failed to load ToolContext from JSON helper: %v", err) } _ = instance // No scalar properties to validate + if len(instance.Messages) != 1 { + t.Fatalf("Expected Messages length to be 1, got %d", len(instance.Messages)) + } + assertToolContextStringField(t, instance.Messages[0], "Role", "user", "Messages[0].Role") if instance.Metadata == nil { t.Fatalf("Expected Metadata to be populated") } @@ -94,6 +142,13 @@ func TestToolContextFromYAML(t *testing.T) { yamlData := ` metadata: userId: user-123 +messages: + - role: user + parts: + - kind: text + value: Hello! + metadata: + source: user-input ` @@ -102,6 +157,10 @@ metadata: t.Fatalf("Failed to load ToolContext from YAML helper: %v", err) } _ = instance // No scalar properties to validate + if len(instance.Messages) != 1 { + t.Fatalf("Expected Messages length to be 1, got %d", len(instance.Messages)) + } + assertToolContextStringField(t, instance.Messages[0], "Role", "user", "Messages[0].Role") if instance.Metadata == nil { t.Fatalf("Expected Metadata to be populated") } @@ -116,7 +175,21 @@ func TestToolContextRoundtrip(t *testing.T) { { "metadata": { "userId": "user-123" - } + }, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "value": "Hello!" + } + ], + "metadata": { + "source": "user-input" + } + } + ] } ` var data map[string]interface{} @@ -137,6 +210,10 @@ func TestToolContextRoundtrip(t *testing.T) { t.Fatalf("Failed to reload ToolContext: %v", err) } _ = reloaded // No scalar properties to validate + if len(reloaded.Messages) != 1 { + t.Fatalf("Expected Messages length to be 1, got %d", len(reloaded.Messages)) + } + assertToolContextStringField(t, reloaded.Messages[0], "Role", "user", "Messages[0].Role") if reloaded.Metadata == nil { t.Fatalf("Expected Metadata to be populated") } @@ -151,7 +228,21 @@ func TestToolContextToJSON(t *testing.T) { { "metadata": { "userId": "user-123" - } + }, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "value": "Hello!" + } + ], + "metadata": { + "source": "user-input" + } + } + ] } ` var data map[string]interface{} @@ -179,6 +270,10 @@ func TestToolContextToJSON(t *testing.T) { t.Fatalf("Failed to reload generated JSON: %v", err) } _ = reloaded // No scalar properties to validate + if len(reloaded.Messages) != 1 { + t.Fatalf("Expected Messages length to be 1, got %d", len(reloaded.Messages)) + } + assertToolContextStringField(t, reloaded.Messages[0], "Role", "user", "Messages[0].Role") if reloaded.Metadata == nil { t.Fatalf("Expected Metadata to be populated") } @@ -193,7 +288,21 @@ func TestToolContextToYAML(t *testing.T) { { "metadata": { "userId": "user-123" - } + }, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "value": "Hello!" + } + ], + "metadata": { + "source": "user-input" + } + } + ] } ` var data map[string]interface{} @@ -221,6 +330,10 @@ func TestToolContextToYAML(t *testing.T) { t.Fatalf("Failed to reload generated YAML: %v", err) } _ = reloaded // No scalar properties to validate + if len(reloaded.Messages) != 1 { + t.Fatalf("Expected Messages length to be 1, got %d", len(reloaded.Messages)) + } + assertToolContextStringField(t, reloaded.Messages[0], "Role", "user", "Messages[0].Role") if reloaded.Metadata == nil { t.Fatalf("Expected Metadata to be populated") } @@ -235,3 +348,39 @@ func TestToolContextFromJSONInvalid(t *testing.T) { t.Fatalf("Expected malformed JSON to fail") } } + +func assertToolContextStringField(t *testing.T, value interface{}, fieldName string, expected string, displayName string) { + t.Helper() + field := reflect.ValueOf(value) + if field.Kind() == reflect.Pointer { + if field.IsNil() { + t.Fatalf("Expected %s to be populated", displayName) + } + field = field.Elem() + } + if field.Kind() != reflect.Struct { + t.Fatalf("Expected %s receiver to be a struct, got %T", displayName, value) + } + member := field.FieldByName(fieldName) + if !member.IsValid() { + t.Fatalf("Expected %s to have field %s, got %T", displayName, fieldName, value) + } + if member.Kind() == reflect.Pointer { + if member.IsNil() { + t.Fatalf("Expected %s to be populated", displayName) + } + member = member.Elem() + } + if member.Kind() == reflect.Interface { + if member.IsNil() { + t.Fatalf("Expected %s to be populated", displayName) + } + member = member.Elem() + } + if member.Kind() != reflect.String { + t.Fatalf("Expected %s to be a string field, got %s", displayName, member.Kind()) + } + if got := member.String(); got != expected { + t.Errorf("Expected %s to be %q, got %q", displayName, expected, got) + } +} diff --git a/runtime/go/prompty/model/tool_dispatch_result.go b/runtime/go/prompty/model/tool_dispatch_result.go index 091b708e4..974a79276 100644 --- a/runtime/go/prompty/model/tool_dispatch_result.go +++ b/runtime/go/prompty/model/tool_dispatch_result.go @@ -6,6 +6,7 @@ package prompty import ( "encoding/json" + "fmt" "gopkg.in/yaml.v3" ) @@ -22,10 +23,16 @@ type ToolDispatchResult struct { // LoadToolDispatchResult creates a ToolDispatchResult from a map[string]interface{} func LoadToolDispatchResult(data interface{}, ctx *LoadContext) (ToolDispatchResult, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ToolDispatchResult{} // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["result"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("result").Path) + } if val, ok := m["toolCallId"]; ok && val != nil { result.ToolCallId = string(val.(string)) } @@ -34,7 +41,7 @@ func LoadToolDispatchResult(data interface{}, ctx *LoadContext) (ToolDispatchRes } if val, ok := m["result"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadToolResult(m, ctx) + loaded, err := LoadToolResult(m, ctx.At("result")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/tool_execution_complete_payload.go b/runtime/go/prompty/model/tool_execution_complete_payload.go index f922d37de..1cba5fbe7 100644 --- a/runtime/go/prompty/model/tool_execution_complete_payload.go +++ b/runtime/go/prompty/model/tool_execution_complete_payload.go @@ -27,6 +27,9 @@ type ToolExecutionCompletePayload struct { // LoadToolExecutionCompletePayload creates a ToolExecutionCompletePayload from a map[string]interface{} func LoadToolExecutionCompletePayload(data interface{}, ctx *LoadContext) (ToolExecutionCompletePayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ToolExecutionCompletePayload{} // Load from map @@ -89,7 +92,7 @@ func LoadToolExecutionCompletePayload(data interface{}, ctx *LoadContext) (ToolE } if val, ok := m["redaction"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadRedactionMetadata(m, ctx) + loaded, err := LoadRedactionMetadata(m, ctx.At("redaction")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/tool_execution_start_payload.go b/runtime/go/prompty/model/tool_execution_start_payload.go index f2e7a639e..3a967ca7f 100644 --- a/runtime/go/prompty/model/tool_execution_start_payload.go +++ b/runtime/go/prompty/model/tool_execution_start_payload.go @@ -26,6 +26,9 @@ type ToolExecutionStartPayload struct { // LoadToolExecutionStartPayload creates a ToolExecutionStartPayload from a map[string]interface{} func LoadToolExecutionStartPayload(data interface{}, ctx *LoadContext) (ToolExecutionStartPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ToolExecutionStartPayload{} // Load from map @@ -52,7 +55,7 @@ func LoadToolExecutionStartPayload(data interface{}, ctx *LoadContext) (ToolExec } if val, ok := m["redaction"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadRedactionMetadata(m, ctx) + loaded, err := LoadRedactionMetadata(m, ctx.At("redaction")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/tool_result.go b/runtime/go/prompty/model/tool_result.go index b119306da..db0490901 100644 --- a/runtime/go/prompty/model/tool_result.go +++ b/runtime/go/prompty/model/tool_result.go @@ -36,6 +36,9 @@ type ToolResult struct { // LoadToolResult creates a ToolResult from a map[string]interface{} func LoadToolResult(data interface{}, ctx *LoadContext) (ToolResult, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ToolResult{} // Load from map @@ -45,7 +48,7 @@ func LoadToolResult(data interface{}, ctx *LoadContext) (ToolResult, error) { result.Parts = make([]interface{}, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadContentPart(item, ctx) + loaded, err := LoadContentPart(item, ctx.At("parts").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/tool_result_payload.go b/runtime/go/prompty/model/tool_result_payload.go index 6f8fed864..322b1c6d9 100644 --- a/runtime/go/prompty/model/tool_result_payload.go +++ b/runtime/go/prompty/model/tool_result_payload.go @@ -6,6 +6,7 @@ package prompty import ( "encoding/json" + "fmt" "gopkg.in/yaml.v3" ) @@ -19,16 +20,22 @@ type ToolResultPayload struct { // LoadToolResultPayload creates a ToolResultPayload from a map[string]interface{} func LoadToolResultPayload(data interface{}, ctx *LoadContext) (ToolResultPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ToolResultPayload{} // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["result"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("result").Path) + } if val, ok := m["name"]; ok && val != nil { result.Name = string(val.(string)) } if val, ok := m["result"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadToolResult(m, ctx) + loaded, err := LoadToolResult(m, ctx.At("result")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/trace_file.go b/runtime/go/prompty/model/trace_file.go index e8b8c48a0..bbf2965c6 100644 --- a/runtime/go/prompty/model/trace_file.go +++ b/runtime/go/prompty/model/trace_file.go @@ -6,6 +6,7 @@ package prompty import ( "encoding/json" + "fmt" "gopkg.in/yaml.v3" ) @@ -20,10 +21,16 @@ type TraceFile struct { // LoadTraceFile creates a TraceFile from a map[string]interface{} func LoadTraceFile(data interface{}, ctx *LoadContext) (TraceFile, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := TraceFile{} // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["trace"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("trace").Path) + } if val, ok := m["runtime"]; ok && val != nil { result.Runtime = string(val.(string)) } @@ -32,7 +39,7 @@ func LoadTraceFile(data interface{}, ctx *LoadContext) (TraceFile, error) { } if val, ok := m["trace"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadTraceSpan(m, ctx) + loaded, err := LoadTraceSpan(m, ctx.At("trace")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/trace_file_test.go b/runtime/go/prompty/model/trace_file_test.go index 4c10029e1..62b7eaa8e 100644 --- a/runtime/go/prompty/model/trace_file_test.go +++ b/runtime/go/prompty/model/trace_file_test.go @@ -17,7 +17,17 @@ func TestTraceFileLoadJSON(t *testing.T) { jsonData := ` { "runtime": "python", - "version": "2.0.0" + "version": "2.0.0", + "trace": { + "name": "prompty.core.pipeline.run", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + }, + "signature": "prompty.core.pipeline.run", + "error": "Connection refused" + } } ` var data map[string]interface{} @@ -36,6 +46,15 @@ func TestTraceFileLoadJSON(t *testing.T) { if instance.Version != "2.0.0" { t.Errorf(`Expected Version to be "2.0.0", got %v`, instance.Version) } + if instance.Trace.Name != "prompty.core.pipeline.run" { + t.Errorf(`Expected Trace.Name to be "prompty.core.pipeline.run", got %v`, instance.Trace.Name) + } + if instance.Trace.Signature == nil || *instance.Trace.Signature != "prompty.core.pipeline.run" { + t.Errorf(`Expected Trace.Signature to be "prompty.core.pipeline.run", got %v`, instance.Trace.Signature) + } + if instance.Trace.Error == nil || *instance.Trace.Error != "Connection refused" { + t.Errorf(`Expected Trace.Error to be "Connection refused", got %v`, instance.Trace.Error) + } } // TestTraceFileLoadYAML tests loading TraceFile from YAML @@ -43,6 +62,14 @@ func TestTraceFileLoadYAML(t *testing.T) { yamlData := ` runtime: python version: 2.0.0 +trace: + name: prompty.core.pipeline.run + __time: + start: "2026-04-04T12:00:00Z" + end: "2026-04-04T12:00:01Z" + duration: 1000 + signature: prompty.core.pipeline.run + error: Connection refused ` var data map[string]interface{} @@ -61,6 +88,15 @@ version: 2.0.0 if instance.Version != "2.0.0" { t.Errorf(`Expected Version to be "2.0.0", got %v`, instance.Version) } + if instance.Trace.Name != "prompty.core.pipeline.run" { + t.Errorf(`Expected Trace.Name to be "prompty.core.pipeline.run", got %v`, instance.Trace.Name) + } + if instance.Trace.Signature == nil || *instance.Trace.Signature != "prompty.core.pipeline.run" { + t.Errorf(`Expected Trace.Signature to be "prompty.core.pipeline.run", got %v`, instance.Trace.Signature) + } + if instance.Trace.Error == nil || *instance.Trace.Error != "Connection refused" { + t.Errorf(`Expected Trace.Error to be "Connection refused", got %v`, instance.Trace.Error) + } } // TestTraceFileFromJSON tests loading TraceFile through the generated JSON helper @@ -68,7 +104,17 @@ func TestTraceFileFromJSON(t *testing.T) { jsonData := ` { "runtime": "python", - "version": "2.0.0" + "version": "2.0.0", + "trace": { + "name": "prompty.core.pipeline.run", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + }, + "signature": "prompty.core.pipeline.run", + "error": "Connection refused" + } } ` @@ -82,6 +128,15 @@ func TestTraceFileFromJSON(t *testing.T) { if instance.Version != "2.0.0" { t.Errorf(`Expected Version to be "2.0.0", got %v`, instance.Version) } + if instance.Trace.Name != "prompty.core.pipeline.run" { + t.Errorf(`Expected Trace.Name to be "prompty.core.pipeline.run", got %v`, instance.Trace.Name) + } + if instance.Trace.Signature == nil || *instance.Trace.Signature != "prompty.core.pipeline.run" { + t.Errorf(`Expected Trace.Signature to be "prompty.core.pipeline.run", got %v`, instance.Trace.Signature) + } + if instance.Trace.Error == nil || *instance.Trace.Error != "Connection refused" { + t.Errorf(`Expected Trace.Error to be "Connection refused", got %v`, instance.Trace.Error) + } } // TestTraceFileFromYAML tests loading TraceFile through the generated YAML helper @@ -89,6 +144,14 @@ func TestTraceFileFromYAML(t *testing.T) { yamlData := ` runtime: python version: 2.0.0 +trace: + name: prompty.core.pipeline.run + __time: + start: "2026-04-04T12:00:00Z" + end: "2026-04-04T12:00:01Z" + duration: 1000 + signature: prompty.core.pipeline.run + error: Connection refused ` @@ -102,6 +165,15 @@ version: 2.0.0 if instance.Version != "2.0.0" { t.Errorf(`Expected Version to be "2.0.0", got %v`, instance.Version) } + if instance.Trace.Name != "prompty.core.pipeline.run" { + t.Errorf(`Expected Trace.Name to be "prompty.core.pipeline.run", got %v`, instance.Trace.Name) + } + if instance.Trace.Signature == nil || *instance.Trace.Signature != "prompty.core.pipeline.run" { + t.Errorf(`Expected Trace.Signature to be "prompty.core.pipeline.run", got %v`, instance.Trace.Signature) + } + if instance.Trace.Error == nil || *instance.Trace.Error != "Connection refused" { + t.Errorf(`Expected Trace.Error to be "Connection refused", got %v`, instance.Trace.Error) + } } // TestTraceFileRoundtrip tests load -> save -> load produces equivalent data @@ -109,7 +181,17 @@ func TestTraceFileRoundtrip(t *testing.T) { jsonData := ` { "runtime": "python", - "version": "2.0.0" + "version": "2.0.0", + "trace": { + "name": "prompty.core.pipeline.run", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + }, + "signature": "prompty.core.pipeline.run", + "error": "Connection refused" + } } ` var data map[string]interface{} @@ -135,6 +217,15 @@ func TestTraceFileRoundtrip(t *testing.T) { if reloaded.Version != "2.0.0" { t.Errorf(`Expected Version to be "2.0.0", got %v`, reloaded.Version) } + if reloaded.Trace.Name != "prompty.core.pipeline.run" { + t.Errorf(`Expected Trace.Name to be "prompty.core.pipeline.run", got %v`, reloaded.Trace.Name) + } + if reloaded.Trace.Signature == nil || *reloaded.Trace.Signature != "prompty.core.pipeline.run" { + t.Errorf(`Expected Trace.Signature to be "prompty.core.pipeline.run", got %v`, reloaded.Trace.Signature) + } + if reloaded.Trace.Error == nil || *reloaded.Trace.Error != "Connection refused" { + t.Errorf(`Expected Trace.Error to be "Connection refused", got %v`, reloaded.Trace.Error) + } } // TestTraceFileToJSON tests that ToJSON produces valid JSON @@ -142,7 +233,17 @@ func TestTraceFileToJSON(t *testing.T) { jsonData := ` { "runtime": "python", - "version": "2.0.0" + "version": "2.0.0", + "trace": { + "name": "prompty.core.pipeline.run", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + }, + "signature": "prompty.core.pipeline.run", + "error": "Connection refused" + } } ` var data map[string]interface{} @@ -175,6 +276,15 @@ func TestTraceFileToJSON(t *testing.T) { if reloaded.Version != "2.0.0" { t.Errorf(`Expected Version to be "2.0.0", got %v`, reloaded.Version) } + if reloaded.Trace.Name != "prompty.core.pipeline.run" { + t.Errorf(`Expected Trace.Name to be "prompty.core.pipeline.run", got %v`, reloaded.Trace.Name) + } + if reloaded.Trace.Signature == nil || *reloaded.Trace.Signature != "prompty.core.pipeline.run" { + t.Errorf(`Expected Trace.Signature to be "prompty.core.pipeline.run", got %v`, reloaded.Trace.Signature) + } + if reloaded.Trace.Error == nil || *reloaded.Trace.Error != "Connection refused" { + t.Errorf(`Expected Trace.Error to be "Connection refused", got %v`, reloaded.Trace.Error) + } } // TestTraceFileToYAML tests that ToYAML produces valid YAML @@ -182,7 +292,17 @@ func TestTraceFileToYAML(t *testing.T) { jsonData := ` { "runtime": "python", - "version": "2.0.0" + "version": "2.0.0", + "trace": { + "name": "prompty.core.pipeline.run", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + }, + "signature": "prompty.core.pipeline.run", + "error": "Connection refused" + } } ` var data map[string]interface{} @@ -215,6 +335,15 @@ func TestTraceFileToYAML(t *testing.T) { if reloaded.Version != "2.0.0" { t.Errorf(`Expected Version to be "2.0.0", got %v`, reloaded.Version) } + if reloaded.Trace.Name != "prompty.core.pipeline.run" { + t.Errorf(`Expected Trace.Name to be "prompty.core.pipeline.run", got %v`, reloaded.Trace.Name) + } + if reloaded.Trace.Signature == nil || *reloaded.Trace.Signature != "prompty.core.pipeline.run" { + t.Errorf(`Expected Trace.Signature to be "prompty.core.pipeline.run", got %v`, reloaded.Trace.Signature) + } + if reloaded.Trace.Error == nil || *reloaded.Trace.Error != "Connection refused" { + t.Errorf(`Expected Trace.Error to be "Connection refused", got %v`, reloaded.Trace.Error) + } } // TestTraceFileFromJSONInvalid rejects malformed JSON instead of silently defaulting diff --git a/runtime/go/prompty/model/trace_span.go b/runtime/go/prompty/model/trace_span.go index 98ddbc8e1..8cfb5dae5 100644 --- a/runtime/go/prompty/model/trace_span.go +++ b/runtime/go/prompty/model/trace_span.go @@ -6,6 +6,7 @@ package prompty import ( "encoding/json" + "fmt" "gopkg.in/yaml.v3" ) @@ -16,32 +17,38 @@ import ( type TraceSpan struct { Name string `json:"name" yaml:"name"` - _Time TraceTime `json:"__time" yaml:"__time"` + Time TraceTime `json:"__time" yaml:"__time"` Signature *string `json:"signature,omitempty" yaml:"signature,omitempty"` Inputs map[string]interface{} `json:"inputs,omitempty" yaml:"inputs,omitempty"` Output *interface{} `json:"output,omitempty" yaml:"output,omitempty"` Error *string `json:"error,omitempty" yaml:"error,omitempty"` - _Usage *TokenUsage `json:"__usage,omitempty" yaml:"__usage,omitempty"` + Usage *TokenUsage `json:"__usage,omitempty" yaml:"__usage,omitempty"` Attributes map[string]interface{} `json:"attributes,omitempty" yaml:"attributes,omitempty"` - _Frames []interface{} `json:"__frames,omitempty" yaml:"__frames,omitempty"` + Frames []interface{} `json:"__frames,omitempty" yaml:"__frames,omitempty"` } // LoadTraceSpan creates a TraceSpan from a map[string]interface{} func LoadTraceSpan(data interface{}, ctx *LoadContext) (TraceSpan, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := TraceSpan{} // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["__time"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("__time").Path) + } if val, ok := m["name"]; ok && val != nil { result.Name = string(val.(string)) } if val, ok := m["__time"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadTraceTime(m, ctx) + loaded, err := LoadTraceTime(m, ctx.At("__time")) if err != nil { return result, err } - result._Time = loaded + result.Time = loaded } } if val, ok := m["signature"]; ok && val != nil { @@ -62,11 +69,11 @@ func LoadTraceSpan(data interface{}, ctx *LoadContext) (TraceSpan, error) { } if val, ok := m["__usage"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadTokenUsage(m, ctx) + loaded, err := LoadTokenUsage(m, ctx.At("__usage")) if err != nil { return result, err } - result._Usage = &loaded + result.Usage = &loaded } } if val, ok := m["attributes"]; ok && val != nil { @@ -77,7 +84,7 @@ func LoadTraceSpan(data interface{}, ctx *LoadContext) (TraceSpan, error) { if val, ok := m["__frames"]; ok && val != nil { switch arr := val.(type) { case []interface{}: - result._Frames = arr + result.Frames = arr } } } @@ -90,7 +97,7 @@ func (obj TraceSpan) Save(ctx *SaveContext) map[string]interface{} { result := make(map[string]interface{}) result["name"] = obj.Name - result["__time"] = obj._Time.Save(ctx) + result["__time"] = obj.Time.Save(ctx) if obj.Signature != nil { result["signature"] = *obj.Signature } @@ -103,13 +110,15 @@ func (obj TraceSpan) Save(ctx *SaveContext) map[string]interface{} { if obj.Error != nil { result["error"] = *obj.Error } - if obj._Usage != nil { - result["__usage"] = obj._Usage.Save(ctx) + if obj.Usage != nil { + result["__usage"] = obj.Usage.Save(ctx) } if obj.Attributes != nil { result["attributes"] = obj.Attributes } - result["__frames"] = obj._Frames + if obj.Frames != nil { + result["__frames"] = obj.Frames + } return result } diff --git a/runtime/go/prompty/model/trace_span_test.go b/runtime/go/prompty/model/trace_span_test.go index 530edfbad..d40754fc2 100644 --- a/runtime/go/prompty/model/trace_span_test.go +++ b/runtime/go/prompty/model/trace_span_test.go @@ -18,7 +18,12 @@ func TestTraceSpanLoadJSON(t *testing.T) { { "name": "prompty.core.pipeline.run", "signature": "prompty.core.pipeline.run", - "error": "Connection refused" + "error": "Connection refused", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + } } ` var data map[string]interface{} @@ -40,6 +45,12 @@ func TestTraceSpanLoadJSON(t *testing.T) { if instance.Error == nil || *instance.Error != "Connection refused" { t.Errorf(`Expected Error to be "Connection refused", got %v`, instance.Error) } + if instance.Time.Start != "2026-04-04T12:00:00Z" { + t.Errorf(`Expected Time.Start to be "2026-04-04T12:00:00Z", got %v`, instance.Time.Start) + } + if instance.Time.End != "2026-04-04T12:00:01Z" { + t.Errorf(`Expected Time.End to be "2026-04-04T12:00:01Z", got %v`, instance.Time.End) + } } // TestTraceSpanLoadYAML tests loading TraceSpan from YAML @@ -48,6 +59,10 @@ func TestTraceSpanLoadYAML(t *testing.T) { name: prompty.core.pipeline.run signature: prompty.core.pipeline.run error: Connection refused +__time: + start: "2026-04-04T12:00:00Z" + end: "2026-04-04T12:00:01Z" + duration: 1000 ` var data map[string]interface{} @@ -69,6 +84,12 @@ error: Connection refused if instance.Error == nil || *instance.Error != "Connection refused" { t.Errorf(`Expected Error to be "Connection refused", got %v`, instance.Error) } + if instance.Time.Start != "2026-04-04T12:00:00Z" { + t.Errorf(`Expected Time.Start to be "2026-04-04T12:00:00Z", got %v`, instance.Time.Start) + } + if instance.Time.End != "2026-04-04T12:00:01Z" { + t.Errorf(`Expected Time.End to be "2026-04-04T12:00:01Z", got %v`, instance.Time.End) + } } // TestTraceSpanFromJSON tests loading TraceSpan through the generated JSON helper @@ -77,7 +98,12 @@ func TestTraceSpanFromJSON(t *testing.T) { { "name": "prompty.core.pipeline.run", "signature": "prompty.core.pipeline.run", - "error": "Connection refused" + "error": "Connection refused", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + } } ` @@ -94,6 +120,12 @@ func TestTraceSpanFromJSON(t *testing.T) { if instance.Error == nil || *instance.Error != "Connection refused" { t.Errorf(`Expected Error to be "Connection refused", got %v`, instance.Error) } + if instance.Time.Start != "2026-04-04T12:00:00Z" { + t.Errorf(`Expected Time.Start to be "2026-04-04T12:00:00Z", got %v`, instance.Time.Start) + } + if instance.Time.End != "2026-04-04T12:00:01Z" { + t.Errorf(`Expected Time.End to be "2026-04-04T12:00:01Z", got %v`, instance.Time.End) + } } // TestTraceSpanFromYAML tests loading TraceSpan through the generated YAML helper @@ -102,6 +134,10 @@ func TestTraceSpanFromYAML(t *testing.T) { name: prompty.core.pipeline.run signature: prompty.core.pipeline.run error: Connection refused +__time: + start: "2026-04-04T12:00:00Z" + end: "2026-04-04T12:00:01Z" + duration: 1000 ` @@ -118,6 +154,12 @@ error: Connection refused if instance.Error == nil || *instance.Error != "Connection refused" { t.Errorf(`Expected Error to be "Connection refused", got %v`, instance.Error) } + if instance.Time.Start != "2026-04-04T12:00:00Z" { + t.Errorf(`Expected Time.Start to be "2026-04-04T12:00:00Z", got %v`, instance.Time.Start) + } + if instance.Time.End != "2026-04-04T12:00:01Z" { + t.Errorf(`Expected Time.End to be "2026-04-04T12:00:01Z", got %v`, instance.Time.End) + } } // TestTraceSpanRoundtrip tests load -> save -> load produces equivalent data @@ -126,7 +168,12 @@ func TestTraceSpanRoundtrip(t *testing.T) { { "name": "prompty.core.pipeline.run", "signature": "prompty.core.pipeline.run", - "error": "Connection refused" + "error": "Connection refused", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + } } ` var data map[string]interface{} @@ -155,6 +202,12 @@ func TestTraceSpanRoundtrip(t *testing.T) { if reloaded.Error == nil || *reloaded.Error != "Connection refused" { t.Errorf(`Expected Error to be "Connection refused", got %v`, reloaded.Error) } + if reloaded.Time.Start != "2026-04-04T12:00:00Z" { + t.Errorf(`Expected Time.Start to be "2026-04-04T12:00:00Z", got %v`, reloaded.Time.Start) + } + if reloaded.Time.End != "2026-04-04T12:00:01Z" { + t.Errorf(`Expected Time.End to be "2026-04-04T12:00:01Z", got %v`, reloaded.Time.End) + } } // TestTraceSpanToJSON tests that ToJSON produces valid JSON @@ -163,7 +216,12 @@ func TestTraceSpanToJSON(t *testing.T) { { "name": "prompty.core.pipeline.run", "signature": "prompty.core.pipeline.run", - "error": "Connection refused" + "error": "Connection refused", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + } } ` var data map[string]interface{} @@ -199,6 +257,12 @@ func TestTraceSpanToJSON(t *testing.T) { if reloaded.Error == nil || *reloaded.Error != "Connection refused" { t.Errorf(`Expected Error to be "Connection refused", got %v`, reloaded.Error) } + if reloaded.Time.Start != "2026-04-04T12:00:00Z" { + t.Errorf(`Expected Time.Start to be "2026-04-04T12:00:00Z", got %v`, reloaded.Time.Start) + } + if reloaded.Time.End != "2026-04-04T12:00:01Z" { + t.Errorf(`Expected Time.End to be "2026-04-04T12:00:01Z", got %v`, reloaded.Time.End) + } } // TestTraceSpanToYAML tests that ToYAML produces valid YAML @@ -207,7 +271,12 @@ func TestTraceSpanToYAML(t *testing.T) { { "name": "prompty.core.pipeline.run", "signature": "prompty.core.pipeline.run", - "error": "Connection refused" + "error": "Connection refused", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + } } ` var data map[string]interface{} @@ -243,6 +312,12 @@ func TestTraceSpanToYAML(t *testing.T) { if reloaded.Error == nil || *reloaded.Error != "Connection refused" { t.Errorf(`Expected Error to be "Connection refused", got %v`, reloaded.Error) } + if reloaded.Time.Start != "2026-04-04T12:00:00Z" { + t.Errorf(`Expected Time.Start to be "2026-04-04T12:00:00Z", got %v`, reloaded.Time.Start) + } + if reloaded.Time.End != "2026-04-04T12:00:01Z" { + t.Errorf(`Expected Time.End to be "2026-04-04T12:00:01Z", got %v`, reloaded.Time.End) + } } // TestTraceSpanFromJSONInvalid rejects malformed JSON instead of silently defaulting diff --git a/runtime/go/prompty/model/trace_time.go b/runtime/go/prompty/model/trace_time.go index ace3171cf..e1fb02f5f 100644 --- a/runtime/go/prompty/model/trace_time.go +++ b/runtime/go/prompty/model/trace_time.go @@ -20,6 +20,9 @@ type TraceTime struct { // LoadTraceTime creates a TraceTime from a map[string]interface{} func LoadTraceTime(data interface{}, ctx *LoadContext) (TraceTime, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := TraceTime{} // Load from map diff --git a/runtime/go/prompty/model/trajectory_event.go b/runtime/go/prompty/model/trajectory_event.go index 32416679e..a1bee4ce7 100644 --- a/runtime/go/prompty/model/trajectory_event.go +++ b/runtime/go/prompty/model/trajectory_event.go @@ -26,6 +26,9 @@ type TrajectoryEvent struct { // LoadTrajectoryEvent creates a TrajectoryEvent from a map[string]interface{} func LoadTrajectoryEvent(data interface{}, ctx *LoadContext) (TrajectoryEvent, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := TrajectoryEvent{} // Load from map @@ -74,7 +77,7 @@ func LoadTrajectoryEvent(data interface{}, ctx *LoadContext) (TrajectoryEvent, e } if val, ok := m["redaction"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadRedactionMetadata(m, ctx) + loaded, err := LoadRedactionMetadata(m, ctx.At("redaction")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/turn_commit.go b/runtime/go/prompty/model/turn_commit.go index afa2c9dff..e98e3e262 100644 --- a/runtime/go/prompty/model/turn_commit.go +++ b/runtime/go/prompty/model/turn_commit.go @@ -6,6 +6,7 @@ package prompty import ( "encoding/json" + "fmt" "gopkg.in/yaml.v3" ) @@ -36,10 +37,18 @@ type TurnCommit struct { // LoadTurnCommit creates a TurnCommit from a map[string]interface{} func LoadTurnCommit(data interface{}, ctx *LoadContext) (TurnCommit, error) { - result := TurnCommit{} + if ctx == nil { + ctx = NewLoadContext() + } + result := TurnCommit{ + Messages: []Message{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["contextState"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("contextState").Path) + } if val, ok := m["sessionId"]; ok && val != nil { result.SessionId = string(val.(string)) } @@ -57,7 +66,7 @@ func LoadTurnCommit(data interface{}, ctx *LoadContext) (TurnCommit, error) { result.Messages = make([]Message, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadMessage(item, ctx) + loaded, err := LoadMessage(item, ctx.At("messages").AtIndex(i)) if err != nil { return result, err } @@ -96,7 +105,7 @@ func LoadTurnCommit(data interface{}, ctx *LoadContext) (TurnCommit, error) { } if val, ok := m["contextState"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadInvocationContextState(m, ctx) + loaded, err := LoadInvocationContextState(m, ctx.At("contextState")) if err != nil { return result, err } @@ -105,7 +114,7 @@ func LoadTurnCommit(data interface{}, ctx *LoadContext) (TurnCommit, error) { } if val, ok := m["modelReconciliation"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadModelReconciliationState(m, ctx) + loaded, err := LoadModelReconciliationState(m, ctx.At("modelReconciliation")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/turn_commit_test.go b/runtime/go/prompty/model/turn_commit_test.go index 510f47352..d80536539 100644 --- a/runtime/go/prompty/model/turn_commit_test.go +++ b/runtime/go/prompty/model/turn_commit_test.go @@ -17,7 +17,8 @@ func TestTurnCommitLoadJSON(t *testing.T) { jsonData := ` { "sessionId": "sess_abc123", - "turnId": "turn_abc123" + "turnId": "turn_abc123", + "contextState": {} } ` var data map[string]interface{} @@ -43,6 +44,7 @@ func TestTurnCommitLoadYAML(t *testing.T) { yamlData := ` sessionId: sess_abc123 turnId: turn_abc123 +contextState: {} ` var data map[string]interface{} @@ -68,7 +70,8 @@ func TestTurnCommitFromJSON(t *testing.T) { jsonData := ` { "sessionId": "sess_abc123", - "turnId": "turn_abc123" + "turnId": "turn_abc123", + "contextState": {} } ` @@ -89,6 +92,7 @@ func TestTurnCommitFromYAML(t *testing.T) { yamlData := ` sessionId: sess_abc123 turnId: turn_abc123 +contextState: {} ` @@ -109,7 +113,8 @@ func TestTurnCommitRoundtrip(t *testing.T) { jsonData := ` { "sessionId": "sess_abc123", - "turnId": "turn_abc123" + "turnId": "turn_abc123", + "contextState": {} } ` var data map[string]interface{} @@ -142,7 +147,8 @@ func TestTurnCommitToJSON(t *testing.T) { jsonData := ` { "sessionId": "sess_abc123", - "turnId": "turn_abc123" + "turnId": "turn_abc123", + "contextState": {} } ` var data map[string]interface{} @@ -182,7 +188,8 @@ func TestTurnCommitToYAML(t *testing.T) { jsonData := ` { "sessionId": "sess_abc123", - "turnId": "turn_abc123" + "turnId": "turn_abc123", + "contextState": {} } ` var data map[string]interface{} diff --git a/runtime/go/prompty/model/turn_end_payload.go b/runtime/go/prompty/model/turn_end_payload.go index 21ac00270..c9ff31f0e 100644 --- a/runtime/go/prompty/model/turn_end_payload.go +++ b/runtime/go/prompty/model/turn_end_payload.go @@ -30,6 +30,9 @@ type TurnEndPayload struct { // LoadTurnEndPayload creates a TurnEndPayload from a map[string]interface{} func LoadTurnEndPayload(data interface{}, ctx *LoadContext) (TurnEndPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := TurnEndPayload{} // Load from map diff --git a/runtime/go/prompty/model/turn_engine_result.go b/runtime/go/prompty/model/turn_engine_result.go index d7262d8cd..293384332 100644 --- a/runtime/go/prompty/model/turn_engine_result.go +++ b/runtime/go/prompty/model/turn_engine_result.go @@ -6,6 +6,7 @@ package prompty import ( "encoding/json" + "fmt" "gopkg.in/yaml.v3" ) @@ -14,20 +15,29 @@ import ( type TurnEngineResult struct { Commit TurnCommit `json:"commit" yaml:"commit"` - Snapshots []ModelInvocationContextSnapshot `json:"snapshots,omitempty" yaml:"snapshots,omitempty"` - ToolResults []ModelToolResult `json:"toolResults,omitempty" yaml:"toolResults,omitempty"` + Snapshots []ModelInvocationContextSnapshot `json:"snapshots" yaml:"snapshots"` + ToolResults []ModelToolResult `json:"toolResults" yaml:"toolResults"` PostCommitError *string `json:"postCommitError,omitempty" yaml:"postCommitError,omitempty"` } // LoadTurnEngineResult creates a TurnEngineResult from a map[string]interface{} func LoadTurnEngineResult(data interface{}, ctx *LoadContext) (TurnEngineResult, error) { - result := TurnEngineResult{} + if ctx == nil { + ctx = NewLoadContext() + } + result := TurnEngineResult{ + Snapshots: []ModelInvocationContextSnapshot{}, + ToolResults: []ModelToolResult{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { + if requiredValue, exists := m["commit"]; !exists || requiredValue == nil { + return result, fmt.Errorf("%s: missing required field", ctx.At("commit").Path) + } if val, ok := m["commit"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadTurnCommit(m, ctx) + loaded, err := LoadTurnCommit(m, ctx.At("commit")) if err != nil { return result, err } @@ -39,7 +49,7 @@ func LoadTurnEngineResult(data interface{}, ctx *LoadContext) (TurnEngineResult, result.Snapshots = make([]ModelInvocationContextSnapshot, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadModelInvocationContextSnapshot(item, ctx) + loaded, err := LoadModelInvocationContextSnapshot(item, ctx.At("snapshots").AtIndex(i)) if err != nil { return result, err } @@ -53,7 +63,7 @@ func LoadTurnEngineResult(data interface{}, ctx *LoadContext) (TurnEngineResult, result.ToolResults = make([]ModelToolResult, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadModelToolResult(item, ctx) + loaded, err := LoadModelToolResult(item, ctx.At("toolResults").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/turn_event.go b/runtime/go/prompty/model/turn_event.go index e8e7eede3..20840b1ab 100644 --- a/runtime/go/prompty/model/turn_event.go +++ b/runtime/go/prompty/model/turn_event.go @@ -57,6 +57,9 @@ type TurnEvent struct { // LoadTurnEvent creates a TurnEvent from a map[string]interface{} func LoadTurnEvent(data interface{}, ctx *LoadContext) (TurnEvent, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := TurnEvent{} // Load from map diff --git a/runtime/go/prompty/model/turn_model_request.go b/runtime/go/prompty/model/turn_model_request.go index 4b9b0db26..1a9eb765d 100644 --- a/runtime/go/prompty/model/turn_model_request.go +++ b/runtime/go/prompty/model/turn_model_request.go @@ -19,14 +19,19 @@ type TurnModelRequest struct { SessionId string `json:"sessionId" yaml:"sessionId"` TurnId string `json:"turnId" yaml:"turnId"` Iteration int32 `json:"iteration" yaml:"iteration"` - Inputs map[string]interface{} `json:"inputs,omitempty" yaml:"inputs,omitempty"` + Inputs map[string]interface{} `json:"inputs" yaml:"inputs"` Options *TurnOptions `json:"options,omitempty" yaml:"options,omitempty"` - ToolResults []HostToolResult `json:"toolResults,omitempty" yaml:"toolResults,omitempty"` + ToolResults []HostToolResult `json:"toolResults" yaml:"toolResults"` } // LoadTurnModelRequest creates a TurnModelRequest from a map[string]interface{} func LoadTurnModelRequest(data interface{}, ctx *LoadContext) (TurnModelRequest, error) { - result := TurnModelRequest{} + if ctx == nil { + ctx = NewLoadContext() + } + result := TurnModelRequest{ + ToolResults: []HostToolResult{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -57,7 +62,7 @@ func LoadTurnModelRequest(data interface{}, ctx *LoadContext) (TurnModelRequest, } if val, ok := m["options"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadTurnOptions(m, ctx) + loaded, err := LoadTurnOptions(m, ctx.At("options")) if err != nil { return result, err } @@ -69,7 +74,7 @@ func LoadTurnModelRequest(data interface{}, ctx *LoadContext) (TurnModelRequest, result.ToolResults = make([]HostToolResult, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadHostToolResult(item, ctx) + loaded, err := LoadHostToolResult(item, ctx.At("toolResults").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/turn_model_response.go b/runtime/go/prompty/model/turn_model_response.go index 2f8d63652..6e9c51db6 100644 --- a/runtime/go/prompty/model/turn_model_response.go +++ b/runtime/go/prompty/model/turn_model_response.go @@ -15,13 +15,18 @@ import ( type TurnModelResponse struct { Output *interface{} `json:"output,omitempty" yaml:"output,omitempty"` Usage *InvocationUsage `json:"usage,omitempty" yaml:"usage,omitempty"` - ToolRequests []HostToolRequest `json:"toolRequests,omitempty" yaml:"toolRequests,omitempty"` - CheckpointState map[string]interface{} `json:"checkpointState,omitempty" yaml:"checkpointState,omitempty"` + ToolRequests []HostToolRequest `json:"toolRequests" yaml:"toolRequests"` + CheckpointState map[string]interface{} `json:"checkpointState" yaml:"checkpointState"` } // LoadTurnModelResponse creates a TurnModelResponse from a map[string]interface{} func LoadTurnModelResponse(data interface{}, ctx *LoadContext) (TurnModelResponse, error) { - result := TurnModelResponse{} + if ctx == nil { + ctx = NewLoadContext() + } + result := TurnModelResponse{ + ToolRequests: []HostToolRequest{}, + } // Load from map if m, ok := data.(map[string]interface{}); ok { @@ -30,7 +35,7 @@ func LoadTurnModelResponse(data interface{}, ctx *LoadContext) (TurnModelRespons } if val, ok := m["usage"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadInvocationUsage(m, ctx) + loaded, err := LoadInvocationUsage(m, ctx.At("usage")) if err != nil { return result, err } @@ -42,7 +47,7 @@ func LoadTurnModelResponse(data interface{}, ctx *LoadContext) (TurnModelRespons result.ToolRequests = make([]HostToolRequest, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadHostToolRequest(item, ctx) + loaded, err := LoadHostToolRequest(item, ctx.At("toolRequests").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/turn_options.go b/runtime/go/prompty/model/turn_options.go index 103bfc4d6..a533cb126 100644 --- a/runtime/go/prompty/model/turn_options.go +++ b/runtime/go/prompty/model/turn_options.go @@ -18,17 +18,20 @@ import ( // field set. type TurnOptions struct { - MaxIterations *int32 `json:"maxIterations,omitempty" yaml:"maxIterations,omitempty"` - MaxLlmRetries *int32 `json:"maxLlmRetries,omitempty" yaml:"maxLlmRetries,omitempty"` + MaxIterations *int32 `json:"maxIterations" yaml:"maxIterations"` + MaxLlmRetries *int32 `json:"maxLlmRetries" yaml:"maxLlmRetries"` ContextBudget *int32 `json:"contextBudget,omitempty" yaml:"contextBudget,omitempty"` - ParallelToolCalls *bool `json:"parallelToolCalls,omitempty" yaml:"parallelToolCalls,omitempty"` - Raw *bool `json:"raw,omitempty" yaml:"raw,omitempty"` + ParallelToolCalls *bool `json:"parallelToolCalls" yaml:"parallelToolCalls"` + Raw *bool `json:"raw" yaml:"raw"` Turn *int32 `json:"turn,omitempty" yaml:"turn,omitempty"` Compaction *CompactionConfig `json:"compaction,omitempty" yaml:"compaction,omitempty"` } // LoadTurnOptions creates a TurnOptions from a map[string]interface{} func LoadTurnOptions(data interface{}, ctx *LoadContext) (TurnOptions, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := TurnOptions{} // Load from map @@ -99,7 +102,7 @@ func LoadTurnOptions(data interface{}, ctx *LoadContext) (TurnOptions, error) { } if val, ok := m["compaction"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadCompactionConfig(m, ctx) + loaded, err := LoadCompactionConfig(m, ctx.At("compaction")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/turn_runner.go b/runtime/go/prompty/model/turn_runner.go index 5c573bfa2..ab2085d4f 100644 --- a/runtime/go/prompty/model/turn_runner.go +++ b/runtime/go/prompty/model/turn_runner.go @@ -223,14 +223,18 @@ func (r *ReferenceTurnRunner) resolveAndExecuteTool(turnId string, iteration int message = *decision.Reason } result := interface{}(map[string]interface{}{"message": message}) - return HostToolResult{ + denied := HostToolResult{ RequestId: toolRequest.RequestId, ToolCallId: toolRequest.ToolCallId, ToolName: toolRequest.ToolName, Success: false, ErrorKind: &errorKind, Result: &result, - }, nil + } + if err := r.recordTurn(TurnEventTypeToolResult, turnId, iteration, denied.Save(NewSaveContext())); err != nil { + return HostToolResult{}, err + } + return denied, nil } if err := r.recordTurn(TurnEventTypeToolExecutionStart, turnId, iteration, toolRequest.Save(NewSaveContext())); err != nil { return HostToolResult{}, err diff --git a/runtime/go/prompty/model/turn_start_payload.go b/runtime/go/prompty/model/turn_start_payload.go index f9c156d91..ce14fea8c 100644 --- a/runtime/go/prompty/model/turn_start_payload.go +++ b/runtime/go/prompty/model/turn_start_payload.go @@ -20,6 +20,9 @@ type TurnStartPayload struct { // LoadTurnStartPayload creates a TurnStartPayload from a map[string]interface{} func LoadTurnStartPayload(data interface{}, ctx *LoadContext) (TurnStartPayload, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := TurnStartPayload{} // Load from map diff --git a/runtime/go/prompty/model/turn_summary.go b/runtime/go/prompty/model/turn_summary.go index d481df1dc..6740a1239 100644 --- a/runtime/go/prompty/model/turn_summary.go +++ b/runtime/go/prompty/model/turn_summary.go @@ -25,6 +25,9 @@ type TurnSummary struct { // LoadTurnSummary creates a TurnSummary from a map[string]interface{} func LoadTurnSummary(data interface{}, ctx *LoadContext) (TurnSummary, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := TurnSummary{} // Load from map @@ -93,7 +96,7 @@ func LoadTurnSummary(data interface{}, ctx *LoadContext) (TurnSummary, error) { } if val, ok := m["usage"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadTokenUsage(m, ctx) + loaded, err := LoadTokenUsage(m, ctx.At("usage")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/turn_trace.go b/runtime/go/prompty/model/turn_trace.go index 1755a40d0..586875091 100644 --- a/runtime/go/prompty/model/turn_trace.go +++ b/runtime/go/prompty/model/turn_trace.go @@ -22,6 +22,9 @@ type TurnTrace struct { // LoadTurnTrace creates a TurnTrace from a map[string]interface{} func LoadTurnTrace(data interface{}, ctx *LoadContext) (TurnTrace, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := TurnTrace{} // Load from map @@ -42,7 +45,7 @@ func LoadTurnTrace(data interface{}, ctx *LoadContext) (TurnTrace, error) { result.Events = make([]TurnEvent, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadTurnEvent(item, ctx) + loaded, err := LoadTurnEvent(item, ctx.At("events").AtIndex(i)) if err != nil { return result, err } @@ -53,7 +56,7 @@ func LoadTurnTrace(data interface{}, ctx *LoadContext) (TurnTrace, error) { } if val, ok := m["summary"]; ok && val != nil { if m, ok := val.(map[string]interface{}); ok { - loaded, err := LoadTurnSummary(m, ctx) + loaded, err := LoadTurnSummary(m, ctx.At("summary")) if err != nil { return result, err } diff --git a/runtime/go/prompty/model/turn_trace_test.go b/runtime/go/prompty/model/turn_trace_test.go index 6a3816a85..9b1cd027f 100644 --- a/runtime/go/prompty/model/turn_trace_test.go +++ b/runtime/go/prompty/model/turn_trace_test.go @@ -5,6 +5,7 @@ package prompty_test import ( "encoding/json" + "reflect" "testing" "gopkg.in/yaml.v3" @@ -18,7 +19,18 @@ func TestTurnTraceLoadJSON(t *testing.T) { { "version": "1", "runtime": "typescript", - "promptyVersion": "2.0.0" + "promptyVersion": "2.0.0", + "events": [ + { + "id": "evt_abc123", + "type": "turn_start", + "timestamp": "2026-06-09T20:00:00Z", + "turnId": "turn_001", + "iteration": 0, + "parentId": "evt_parent", + "spanId": "span_tool_001" + } + ] } ` var data map[string]interface{} @@ -40,6 +52,15 @@ func TestTurnTraceLoadJSON(t *testing.T) { if instance.PromptyVersion == nil || *instance.PromptyVersion != "2.0.0" { t.Errorf(`Expected PromptyVersion to be "2.0.0", got %v`, instance.PromptyVersion) } + if len(instance.Events) != 1 { + t.Fatalf("Expected Events length to be 1, got %d", len(instance.Events)) + } + assertTurnTraceStringField(t, instance.Events[0], "Id", "evt_abc123", "Events[0].Id") + assertTurnTraceStringField(t, instance.Events[0], "Type", "turn_start", "Events[0].Type") + assertTurnTraceStringField(t, instance.Events[0], "Timestamp", "2026-06-09T20:00:00Z", "Events[0].Timestamp") + assertTurnTraceStringField(t, instance.Events[0], "TurnId", "turn_001", "Events[0].TurnId") + assertTurnTraceStringField(t, instance.Events[0], "ParentId", "evt_parent", "Events[0].ParentId") + assertTurnTraceStringField(t, instance.Events[0], "SpanId", "span_tool_001", "Events[0].SpanId") } // TestTurnTraceLoadYAML tests loading TurnTrace from YAML @@ -48,6 +69,14 @@ func TestTurnTraceLoadYAML(t *testing.T) { version: "1" runtime: typescript promptyVersion: 2.0.0 +events: + - id: evt_abc123 + type: turn_start + timestamp: "2026-06-09T20:00:00Z" + turnId: turn_001 + iteration: 0 + parentId: evt_parent + spanId: span_tool_001 ` var data map[string]interface{} @@ -69,6 +98,15 @@ promptyVersion: 2.0.0 if instance.PromptyVersion == nil || *instance.PromptyVersion != "2.0.0" { t.Errorf(`Expected PromptyVersion to be "2.0.0", got %v`, instance.PromptyVersion) } + if len(instance.Events) != 1 { + t.Fatalf("Expected Events length to be 1, got %d", len(instance.Events)) + } + assertTurnTraceStringField(t, instance.Events[0], "Id", "evt_abc123", "Events[0].Id") + assertTurnTraceStringField(t, instance.Events[0], "Type", "turn_start", "Events[0].Type") + assertTurnTraceStringField(t, instance.Events[0], "Timestamp", "2026-06-09T20:00:00Z", "Events[0].Timestamp") + assertTurnTraceStringField(t, instance.Events[0], "TurnId", "turn_001", "Events[0].TurnId") + assertTurnTraceStringField(t, instance.Events[0], "ParentId", "evt_parent", "Events[0].ParentId") + assertTurnTraceStringField(t, instance.Events[0], "SpanId", "span_tool_001", "Events[0].SpanId") } // TestTurnTraceFromJSON tests loading TurnTrace through the generated JSON helper @@ -77,7 +115,18 @@ func TestTurnTraceFromJSON(t *testing.T) { { "version": "1", "runtime": "typescript", - "promptyVersion": "2.0.0" + "promptyVersion": "2.0.0", + "events": [ + { + "id": "evt_abc123", + "type": "turn_start", + "timestamp": "2026-06-09T20:00:00Z", + "turnId": "turn_001", + "iteration": 0, + "parentId": "evt_parent", + "spanId": "span_tool_001" + } + ] } ` @@ -94,6 +143,15 @@ func TestTurnTraceFromJSON(t *testing.T) { if instance.PromptyVersion == nil || *instance.PromptyVersion != "2.0.0" { t.Errorf(`Expected PromptyVersion to be "2.0.0", got %v`, instance.PromptyVersion) } + if len(instance.Events) != 1 { + t.Fatalf("Expected Events length to be 1, got %d", len(instance.Events)) + } + assertTurnTraceStringField(t, instance.Events[0], "Id", "evt_abc123", "Events[0].Id") + assertTurnTraceStringField(t, instance.Events[0], "Type", "turn_start", "Events[0].Type") + assertTurnTraceStringField(t, instance.Events[0], "Timestamp", "2026-06-09T20:00:00Z", "Events[0].Timestamp") + assertTurnTraceStringField(t, instance.Events[0], "TurnId", "turn_001", "Events[0].TurnId") + assertTurnTraceStringField(t, instance.Events[0], "ParentId", "evt_parent", "Events[0].ParentId") + assertTurnTraceStringField(t, instance.Events[0], "SpanId", "span_tool_001", "Events[0].SpanId") } // TestTurnTraceFromYAML tests loading TurnTrace through the generated YAML helper @@ -102,6 +160,14 @@ func TestTurnTraceFromYAML(t *testing.T) { version: "1" runtime: typescript promptyVersion: 2.0.0 +events: + - id: evt_abc123 + type: turn_start + timestamp: "2026-06-09T20:00:00Z" + turnId: turn_001 + iteration: 0 + parentId: evt_parent + spanId: span_tool_001 ` @@ -118,6 +184,15 @@ promptyVersion: 2.0.0 if instance.PromptyVersion == nil || *instance.PromptyVersion != "2.0.0" { t.Errorf(`Expected PromptyVersion to be "2.0.0", got %v`, instance.PromptyVersion) } + if len(instance.Events) != 1 { + t.Fatalf("Expected Events length to be 1, got %d", len(instance.Events)) + } + assertTurnTraceStringField(t, instance.Events[0], "Id", "evt_abc123", "Events[0].Id") + assertTurnTraceStringField(t, instance.Events[0], "Type", "turn_start", "Events[0].Type") + assertTurnTraceStringField(t, instance.Events[0], "Timestamp", "2026-06-09T20:00:00Z", "Events[0].Timestamp") + assertTurnTraceStringField(t, instance.Events[0], "TurnId", "turn_001", "Events[0].TurnId") + assertTurnTraceStringField(t, instance.Events[0], "ParentId", "evt_parent", "Events[0].ParentId") + assertTurnTraceStringField(t, instance.Events[0], "SpanId", "span_tool_001", "Events[0].SpanId") } // TestTurnTraceRoundtrip tests load -> save -> load produces equivalent data @@ -126,7 +201,18 @@ func TestTurnTraceRoundtrip(t *testing.T) { { "version": "1", "runtime": "typescript", - "promptyVersion": "2.0.0" + "promptyVersion": "2.0.0", + "events": [ + { + "id": "evt_abc123", + "type": "turn_start", + "timestamp": "2026-06-09T20:00:00Z", + "turnId": "turn_001", + "iteration": 0, + "parentId": "evt_parent", + "spanId": "span_tool_001" + } + ] } ` var data map[string]interface{} @@ -155,6 +241,15 @@ func TestTurnTraceRoundtrip(t *testing.T) { if reloaded.PromptyVersion == nil || *reloaded.PromptyVersion != "2.0.0" { t.Errorf(`Expected PromptyVersion to be "2.0.0", got %v`, reloaded.PromptyVersion) } + if len(reloaded.Events) != 1 { + t.Fatalf("Expected Events length to be 1, got %d", len(reloaded.Events)) + } + assertTurnTraceStringField(t, reloaded.Events[0], "Id", "evt_abc123", "Events[0].Id") + assertTurnTraceStringField(t, reloaded.Events[0], "Type", "turn_start", "Events[0].Type") + assertTurnTraceStringField(t, reloaded.Events[0], "Timestamp", "2026-06-09T20:00:00Z", "Events[0].Timestamp") + assertTurnTraceStringField(t, reloaded.Events[0], "TurnId", "turn_001", "Events[0].TurnId") + assertTurnTraceStringField(t, reloaded.Events[0], "ParentId", "evt_parent", "Events[0].ParentId") + assertTurnTraceStringField(t, reloaded.Events[0], "SpanId", "span_tool_001", "Events[0].SpanId") } // TestTurnTraceToJSON tests that ToJSON produces valid JSON @@ -163,7 +258,18 @@ func TestTurnTraceToJSON(t *testing.T) { { "version": "1", "runtime": "typescript", - "promptyVersion": "2.0.0" + "promptyVersion": "2.0.0", + "events": [ + { + "id": "evt_abc123", + "type": "turn_start", + "timestamp": "2026-06-09T20:00:00Z", + "turnId": "turn_001", + "iteration": 0, + "parentId": "evt_parent", + "spanId": "span_tool_001" + } + ] } ` var data map[string]interface{} @@ -199,6 +305,15 @@ func TestTurnTraceToJSON(t *testing.T) { if reloaded.PromptyVersion == nil || *reloaded.PromptyVersion != "2.0.0" { t.Errorf(`Expected PromptyVersion to be "2.0.0", got %v`, reloaded.PromptyVersion) } + if len(reloaded.Events) != 1 { + t.Fatalf("Expected Events length to be 1, got %d", len(reloaded.Events)) + } + assertTurnTraceStringField(t, reloaded.Events[0], "Id", "evt_abc123", "Events[0].Id") + assertTurnTraceStringField(t, reloaded.Events[0], "Type", "turn_start", "Events[0].Type") + assertTurnTraceStringField(t, reloaded.Events[0], "Timestamp", "2026-06-09T20:00:00Z", "Events[0].Timestamp") + assertTurnTraceStringField(t, reloaded.Events[0], "TurnId", "turn_001", "Events[0].TurnId") + assertTurnTraceStringField(t, reloaded.Events[0], "ParentId", "evt_parent", "Events[0].ParentId") + assertTurnTraceStringField(t, reloaded.Events[0], "SpanId", "span_tool_001", "Events[0].SpanId") } // TestTurnTraceToYAML tests that ToYAML produces valid YAML @@ -207,7 +322,18 @@ func TestTurnTraceToYAML(t *testing.T) { { "version": "1", "runtime": "typescript", - "promptyVersion": "2.0.0" + "promptyVersion": "2.0.0", + "events": [ + { + "id": "evt_abc123", + "type": "turn_start", + "timestamp": "2026-06-09T20:00:00Z", + "turnId": "turn_001", + "iteration": 0, + "parentId": "evt_parent", + "spanId": "span_tool_001" + } + ] } ` var data map[string]interface{} @@ -243,6 +369,15 @@ func TestTurnTraceToYAML(t *testing.T) { if reloaded.PromptyVersion == nil || *reloaded.PromptyVersion != "2.0.0" { t.Errorf(`Expected PromptyVersion to be "2.0.0", got %v`, reloaded.PromptyVersion) } + if len(reloaded.Events) != 1 { + t.Fatalf("Expected Events length to be 1, got %d", len(reloaded.Events)) + } + assertTurnTraceStringField(t, reloaded.Events[0], "Id", "evt_abc123", "Events[0].Id") + assertTurnTraceStringField(t, reloaded.Events[0], "Type", "turn_start", "Events[0].Type") + assertTurnTraceStringField(t, reloaded.Events[0], "Timestamp", "2026-06-09T20:00:00Z", "Events[0].Timestamp") + assertTurnTraceStringField(t, reloaded.Events[0], "TurnId", "turn_001", "Events[0].TurnId") + assertTurnTraceStringField(t, reloaded.Events[0], "ParentId", "evt_parent", "Events[0].ParentId") + assertTurnTraceStringField(t, reloaded.Events[0], "SpanId", "span_tool_001", "Events[0].SpanId") } // TestTurnTraceFromJSONInvalid rejects malformed JSON instead of silently defaulting @@ -251,3 +386,39 @@ func TestTurnTraceFromJSONInvalid(t *testing.T) { t.Fatalf("Expected malformed JSON to fail") } } + +func assertTurnTraceStringField(t *testing.T, value interface{}, fieldName string, expected string, displayName string) { + t.Helper() + field := reflect.ValueOf(value) + if field.Kind() == reflect.Pointer { + if field.IsNil() { + t.Fatalf("Expected %s to be populated", displayName) + } + field = field.Elem() + } + if field.Kind() != reflect.Struct { + t.Fatalf("Expected %s receiver to be a struct, got %T", displayName, value) + } + member := field.FieldByName(fieldName) + if !member.IsValid() { + t.Fatalf("Expected %s to have field %s, got %T", displayName, fieldName, value) + } + if member.Kind() == reflect.Pointer { + if member.IsNil() { + t.Fatalf("Expected %s to be populated", displayName) + } + member = member.Elem() + } + if member.Kind() == reflect.Interface { + if member.IsNil() { + t.Fatalf("Expected %s to be populated", displayName) + } + member = member.Elem() + } + if member.Kind() != reflect.String { + t.Fatalf("Expected %s to be a string field, got %s", displayName, member.Kind()) + } + if got := member.String(); got != expected { + t.Errorf("Expected %s to be %q, got %q", displayName, expected, got) + } +} diff --git a/runtime/go/prompty/model/validation_error.go b/runtime/go/prompty/model/validation_error.go index 25386ef3d..80f5c875b 100644 --- a/runtime/go/prompty/model/validation_error.go +++ b/runtime/go/prompty/model/validation_error.go @@ -21,6 +21,9 @@ type ValidationError struct { // LoadValidationError creates a ValidationError from a map[string]interface{} func LoadValidationError(data interface{}, ctx *LoadContext) (ValidationError, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ValidationError{} // Load from map diff --git a/runtime/go/prompty/model/validation_result.go b/runtime/go/prompty/model/validation_result.go index f43de1fc2..cd4990621 100644 --- a/runtime/go/prompty/model/validation_result.go +++ b/runtime/go/prompty/model/validation_result.go @@ -21,6 +21,9 @@ type ValidationResult struct { // LoadValidationResult creates a ValidationResult from a map[string]interface{} func LoadValidationResult(data interface{}, ctx *LoadContext) (ValidationResult, error) { + if ctx == nil { + ctx = NewLoadContext() + } result := ValidationResult{} // Load from map @@ -33,7 +36,7 @@ func LoadValidationResult(data interface{}, ctx *LoadContext) (ValidationResult, result.Errors = make([]ValidationError, len(arr)) for i, v := range arr { if item, ok := v.(map[string]interface{}); ok { - loaded, err := LoadValidationError(item, ctx) + loaded, err := LoadValidationError(item, ctx.At("errors").AtIndex(i)) if err != nil { return result, err } diff --git a/runtime/python/prompty/.env.example b/runtime/python/prompty/.env.example index 782293591..e8b9ef316 100644 --- a/runtime/python/prompty/.env.example +++ b/runtime/python/prompty/.env.example @@ -9,7 +9,10 @@ OPENAI_API_KEY= OPENAI_BASE_URL= OPENAI_MODEL=gpt-4o-mini OPENAI_EMBEDDING_MODEL=text-embedding-3-small -OPENAI_IMAGE_MODEL=dall-e-2 +# Image generation is opt-in and billable; leave blank to skip those tests. +# Model availability is account-specific: dall-e-2 / dall-e-3 are retired on +# current accounts (400 "does not exist"); newer accounts expose gpt-image-1. +OPENAI_IMAGE_MODEL= # Direct OpenAI (api.openai.com — no proxy/compat layer) DIRECT_OPENAI_API_KEY= diff --git a/runtime/python/prompty/README.md b/runtime/python/prompty/README.md index a4e1729e1..985e8b017 100644 --- a/runtime/python/prompty/README.md +++ b/runtime/python/prompty/README.md @@ -72,25 +72,19 @@ Say hello to {{name}}. import prompty # One-shot: load + prepare + run -result = prompty.invoke( - "greeting.prompty", inputs={"name": "Jane"} -) +result = prompty.invoke("greeting.prompty", inputs={"name": "Jane"}) print(result) # Step-by-step agent = prompty.load("greeting.prompty") -messages = prompty.prepare( - agent, inputs={"name": "Jane"} -) +messages = prompty.prepare(agent, inputs={"name": "Jane"}) result = prompty.run(agent, messages) ``` ### 3. Async ```python -result = await prompty.invoke_async( - "greeting.prompty", inputs={"name": "Jane"} -) +result = await prompty.invoke_async("greeting.prompty", inputs={"name": "Jane"}) ``` ## API Reference @@ -118,6 +112,7 @@ All functions have `_async` variants (e.g., def get_weather(location: str) -> str: return f"72°F and sunny in {location}" + result = prompty.turn( "my-agent.prompty", inputs={"question": "Weather in Seattle?"}, @@ -170,9 +165,7 @@ client = AzureOpenAI( prompty.register_connection("my-foundry", client=client) # Now run — executor resolves the client by name -result = prompty.invoke( - "my-prompt.prompty", inputs={...} -) +result = prompty.invoke("my-prompt.prompty", inputs={...}) ``` ### Structured Output @@ -198,9 +191,7 @@ JSON-parses the result. ```python agent = prompty.load("chat.prompty") -messages = prompty.prepare( - agent, inputs={"question": "Tell me a story"} -) +messages = prompty.prepare(agent, inputs={"question": "Tell me a story"}) # Set stream option agent.model.options.additionalProperties = { @@ -224,25 +215,22 @@ from prompty import Tracer, PromptyTracer, trace # Register a tracer Tracer.add("console", prompty.console_tracer) -Tracer.add( - "json", PromptyTracer("./traces").tracer -) +Tracer.add("json", PromptyTracer("./traces").tracer) # All pipeline functions automatically emit traces -result = prompty.invoke( - "my-prompt.prompty", inputs={...} -) +result = prompty.invoke("my-prompt.prompty", inputs={...}) + # Custom functions @trace -def my_function(): - ... +def my_function(): ... ``` OpenTelemetry integration: ```python from prompty.tracing.otel import otel_tracer + Tracer.add("otel", otel_tracer()) ``` diff --git a/runtime/python/prompty/prompty/core/types.py b/runtime/python/prompty/prompty/core/types.py index b5ae4ee5c..d0719039d 100644 --- a/runtime/python/prompty/prompty/core/types.py +++ b/runtime/python/prompty/prompty/core/types.py @@ -59,8 +59,8 @@ def _message_text(self: Message) -> str: - """Concatenate all TextPart values into a single string.""" - return "".join(p.value for p in self.parts if isinstance(p, TextPart)) + """Concatenate all TextPart values joined by newline.""" + return "\n".join(p.value for p in self.parts if isinstance(p, TextPart)) def _message_to_text_content(self: Message) -> str | list[dict[str, Any]]: diff --git a/runtime/python/prompty/prompty/harness/turn_runner.py b/runtime/python/prompty/prompty/harness/turn_runner.py index 0b9ecdef5..baa5d627c 100644 --- a/runtime/python/prompty/prompty/harness/turn_runner.py +++ b/runtime/python/prompty/prompty/harness/turn_runner.py @@ -243,7 +243,7 @@ async def _resolve_and_execute_tool( self._record_turn("permission_completed", turn_id, iteration, decision.save()) if not decision.approved: - return HostToolResult( + denied = HostToolResult( request_id=tool_request.request_id, tool_call_id=tool_request.tool_call_id, tool_name=tool_request.tool_name, @@ -251,6 +251,8 @@ async def _resolve_and_execute_tool( error_kind="permission_denied", result={"message": decision.reason or "Permission denied"}, ) + self._record_turn("tool_result", turn_id, iteration, denied.save()) + return denied self._record_turn("tool_execution_start", turn_id, iteration, tool_request.save()) result = await self.host_tool_executor.execute(tool_request) diff --git a/runtime/python/prompty/prompty/model/__init__.py b/runtime/python/prompty/prompty/model/__init__.py index 57869e118..6e71baa49 100644 --- a/runtime/python/prompty/prompty/model/__init__.py +++ b/runtime/python/prompty/prompty/model/__init__.py @@ -117,8 +117,12 @@ ContextRequest, DelegatedStateReference, EngineCheckpoint, + EngineDurabilityPort, EngineEvent, EnginePermissionDecision, + EnginePermissionPort, + EnginePostCommitPort, + EngineToolPort, EventJournalWriter, EventSink, Executor, @@ -261,6 +265,10 @@ "ResumeContext", "TurnCommit", "TurnEngineResult", + "EnginePermissionPort", + "EngineToolPort", + "EngineDurabilityPort", + "EnginePostCommitPort", "HostPolicyRequest", "HostPolicyResult", "FinalOutputPolicyRequest", diff --git a/runtime/python/prompty/prompty/model/_context.py b/runtime/python/prompty/prompty/model/_context.py index c29e102c6..888622004 100644 --- a/runtime/python/prompty/prompty/model/_context.py +++ b/runtime/python/prompty/prompty/model/_context.py @@ -23,6 +23,29 @@ class LoadContext: post_process: Callable[[Any], Any] | None = None """Optional callback to transform the result after instantiation.""" + path: str = "" + """Current schema path used for load diagnostics.""" + + def at(self, segment: str) -> "LoadContext": + return LoadContext( + pre_process=self.pre_process, + post_process=self.post_process, + path=f"{self.path}.{segment}" if self.path else segment, + ) + + def at_index(self, index: int) -> "LoadContext": + """Descend into an array element. + + Rendered with bracket notation (messages[3]) so an index is never + confused with a map key of the same name, which dot-joining would make + ambiguous. + """ + return LoadContext( + pre_process=self.pre_process, + post_process=self.post_process, + path=f"{self.path}[{index}]", + ) + def process_input(self, data: dict[str, Any]) -> dict[str, Any]: """ Apply pre-processing to input data if a pre_process callback is set. diff --git a/runtime/python/prompty/prompty/model/agent/_GuardrailResult.py b/runtime/python/prompty/prompty/model/agent/_GuardrailResult.py index ed297f009..68bdabeb9 100644 --- a/runtime/python/prompty/prompty/model/agent/_GuardrailResult.py +++ b/runtime/python/prompty/prompty/model/agent/_GuardrailResult.py @@ -44,8 +44,9 @@ def load(data: Any, context: LoadContext | None = None) -> "GuardrailResult": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for GuardrailResult: {data}") diff --git a/runtime/python/prompty/prompty/model/agent/_Prompty.py b/runtime/python/prompty/prompty/model/agent/_Prompty.py index 174dd5d1b..b306905e9 100644 --- a/runtime/python/prompty/prompty/model/agent/_Prompty.py +++ b/runtime/python/prompty/prompty/model/agent/_Prompty.py @@ -39,12 +39,12 @@ class or kind discriminator. A .prompty file always produces a Prompty instance. description : Optional[str] Description of the prompt's purpose metadata : Optional[dict[str, Any]] - Additional metadata including authors, tags, and other arbitrary properties + Additional metadata including authors, tags, and other arbitrary properties. Values may be explicit null. inputs : Optional[list[Property]] Input parameters that participate in template rendering outputs : Optional[list[Property]] Expected output format and structure - model : Model + model : Optional[Model] AI model configuration tools : Optional[list[Tool]] Tools available for extended functionality @@ -60,10 +60,10 @@ class or kind discriminator. A .prompty file always produces a Prompty instance. display_name: str | None = None description: str | None = None metadata: dict[str, Any] | None = None - inputs: list[Property] = field(default_factory=list) - outputs: list[Property] = field(default_factory=list) - model: Model = field(default_factory=Model) - tools: list[Tool] = field(default_factory=list) + inputs: list[Property] | None = None + outputs: list[Property] | None = None + model: Model | None = None + tools: list[Tool] | None = field(default_factory=list) template: Template | None = None instructions: str | None = None @@ -78,8 +78,9 @@ def load(data: Any, context: LoadContext | None = None) -> "Prompty": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for Prompty: {data}") @@ -96,15 +97,15 @@ def load(data: Any, context: LoadContext | None = None) -> "Prompty": if data is not None and "metadata" in data: instance.metadata = data["metadata"] if data is not None and "inputs" in data: - instance.inputs = Prompty.load_inputs(data["inputs"], context) + instance.inputs = Prompty.load_inputs(data["inputs"], context.at("inputs")) if data is not None and "outputs" in data: - instance.outputs = Prompty.load_outputs(data["outputs"], context) + instance.outputs = Prompty.load_outputs(data["outputs"], context.at("outputs")) if data is not None and "model" in data: - instance.model = Model.load(data["model"], context) + instance.model = Model.load(data["model"], context.at("model")) if data is not None and "tools" in data: - instance.tools = Prompty.load_tools(data["tools"], context) + instance.tools = Prompty.load_tools(data["tools"], context.at("tools")) if data is not None and "template" in data: - instance.template = Template.load(data["template"], context) + instance.template = Template.load(data["template"], context.at("template")) if data is not None and "instructions" in data: instance.instructions = data["instructions"] if context is not None: @@ -113,131 +114,178 @@ def load(data: Any, context: LoadContext | None = None) -> "Prompty": @staticmethod def load_inputs(data: dict | list, context: LoadContext | None) -> list[Property]: + if context is None: + context = LoadContext(path="inputs") if isinstance(data, dict): # convert simple named inputs to list of Property result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Property.load({"name": k, **v}, context.at(k))) else: - # value is a scalar, use it as the primary property - result.append({"name": k, "kind": v}) - data = result - return [Property.load(item, context) for item in data] + # value is a scalar, infer the entry shape from its type + if isinstance(v, int) and not isinstance(v, bool): + shorthand = {"kind": "integer", "default": v} + elif isinstance(v, float): + shorthand = {"kind": "float", "default": v} + elif isinstance(v, str): + shorthand = {"kind": "string", "default": v} + elif isinstance(v, bool): + shorthand = {"kind": "boolean", "default": v} + else: + shorthand = {"default": v} + result.append(Property.load({"name": k, **shorthand}, context.at(k))) + return result + return [Property.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_inputs(items: list[Property], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() + serialized = [dict(item.save(context)) for item in items] + for item_data in serialized: + if item_data.get("name") == "": + item_data.pop("name") + if context.collection_format == "array": - return [item.save(context) for item in items] + return serialized + + names: set[str] = set() + for item_data in serialized: + name = item_data.get("name") + if not isinstance(name, str) or not name or name in names: + return serialized + names.add(name) # Object format: use name as key result: dict[str, Any] = {} - for item in items: - item_data = item.save(context) - name = item_data.pop("name", None) - if name: - # Check if we can use shorthand (only primary property set) - if context.use_shorthand and hasattr(item, "_shorthand_property"): - shorthand_prop = item._shorthand_property - if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: - result[name] = item_data[shorthand_prop] - continue - result[name] = item_data - else: - # No name, fall back to array format for this item - if "_unnamed" not in result: - result["_unnamed"] = [] - result["_unnamed"].append(item_data) + for item, item_data in zip(items, serialized): + name = item_data.pop("name") + # Check if we can use shorthand (only primary property set) + if context.use_shorthand and hasattr(item, "_shorthand_property"): + shorthand_prop = item._shorthand_property + if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: + result[name] = item_data[shorthand_prop] + continue + result[name] = item_data return result @staticmethod def load_outputs(data: dict | list, context: LoadContext | None) -> list[Property]: + if context is None: + context = LoadContext(path="outputs") if isinstance(data, dict): # convert simple named outputs to list of Property result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Property.load({"name": k, **v}, context.at(k))) else: - # value is a scalar, use it as the primary property - result.append({"name": k, "kind": v}) - data = result - return [Property.load(item, context) for item in data] + # value is a scalar, infer the entry shape from its type + if isinstance(v, int) and not isinstance(v, bool): + shorthand = {"kind": "integer", "default": v} + elif isinstance(v, float): + shorthand = {"kind": "float", "default": v} + elif isinstance(v, str): + shorthand = {"kind": "string", "default": v} + elif isinstance(v, bool): + shorthand = {"kind": "boolean", "default": v} + else: + shorthand = {"default": v} + result.append(Property.load({"name": k, **shorthand}, context.at(k))) + return result + return [Property.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_outputs(items: list[Property], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() + serialized = [dict(item.save(context)) for item in items] + for item_data in serialized: + if item_data.get("name") == "": + item_data.pop("name") + if context.collection_format == "array": - return [item.save(context) for item in items] + return serialized + + names: set[str] = set() + for item_data in serialized: + name = item_data.get("name") + if not isinstance(name, str) or not name or name in names: + return serialized + names.add(name) # Object format: use name as key result: dict[str, Any] = {} - for item in items: - item_data = item.save(context) - name = item_data.pop("name", None) - if name: - # Check if we can use shorthand (only primary property set) - if context.use_shorthand and hasattr(item, "_shorthand_property"): - shorthand_prop = item._shorthand_property - if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: - result[name] = item_data[shorthand_prop] - continue - result[name] = item_data - else: - # No name, fall back to array format for this item - if "_unnamed" not in result: - result["_unnamed"] = [] - result["_unnamed"].append(item_data) + for item, item_data in zip(items, serialized): + name = item_data.pop("name") + # Check if we can use shorthand (only primary property set) + if context.use_shorthand and hasattr(item, "_shorthand_property"): + shorthand_prop = item._shorthand_property + if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: + result[name] = item_data[shorthand_prop] + continue + result[name] = item_data return result @staticmethod def load_tools(data: dict | list, context: LoadContext | None) -> list[Tool]: + if context is None: + context = LoadContext(path="tools") if isinstance(data, dict): # convert simple named tools to list of Tool result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Tool.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "kind": v}) - data = result - return [Tool.load(item, context) for item in data] + result.append(Tool.load({"name": k, "kind": v}, context.at(k))) + return result + return [Tool.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_tools(items: list[Tool], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() + serialized = [dict(item.save(context)) for item in items] + for item_data in serialized: + if item_data.get("name") == "": + item_data.pop("name") + if context.collection_format == "array": - return [item.save(context) for item in items] + return serialized + + names: set[str] = set() + for item_data in serialized: + name = item_data.get("name") + if not isinstance(name, str) or not name or name in names: + return serialized + names.add(name) # Object format: use name as key result: dict[str, Any] = {} - for item in items: - item_data = item.save(context) - name = item_data.pop("name", None) - if name: - # Check if we can use shorthand (only primary property set) - if context.use_shorthand and hasattr(item, "_shorthand_property"): - shorthand_prop = item._shorthand_property - if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: - result[name] = item_data[shorthand_prop] - continue - result[name] = item_data - else: - # No name, fall back to array format for this item - if "_unnamed" not in result: - result["_unnamed"] = [] - result["_unnamed"].append(item_data) + for item, item_data in zip(items, serialized): + name = item_data.pop("name") + # Check if we can use shorthand (only primary property set) + if context.use_shorthand and hasattr(item, "_shorthand_property"): + shorthand_prop = item._shorthand_property + if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: + result[name] = item_data[shorthand_prop] + continue + result[name] = item_data return result def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/connection/_AuthorizationCodeFlow.py b/runtime/python/prompty/prompty/model/connection/_AuthorizationCodeFlow.py index 04e80587d..6eedf06d3 100644 --- a/runtime/python/prompty/prompty/model/connection/_AuthorizationCodeFlow.py +++ b/runtime/python/prompty/prompty/model/connection/_AuthorizationCodeFlow.py @@ -39,8 +39,9 @@ def load(data: Any, context: LoadContext | None = None) -> "AuthorizationCodeFlo """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for AuthorizationCodeFlow: {data}") diff --git a/runtime/python/prompty/prompty/model/connection/_Connection.py b/runtime/python/prompty/prompty/model/connection/_Connection.py index 9df3d76fa..6a1ff1c45 100644 --- a/runtime/python/prompty/prompty/model/connection/_Connection.py +++ b/runtime/python/prompty/prompty/model/connection/_Connection.py @@ -5,6 +5,7 @@ # ANY EDITS WILL BE LOST ########################################## +import copy from abc import ABC from dataclasses import dataclass, field from typing import Any, ClassVar, Literal @@ -35,6 +36,7 @@ class Connection(ABC): kind: str = field(default="") authentication_mode: AuthenticationMode | None = None usage_description: str | None = None + _raw: dict[str, Any] = field(default_factory=dict, init=False, repr=False) @staticmethod def load(data: Any, context: LoadContext | None = None) -> "Connection": @@ -47,8 +49,9 @@ def load(data: Any, context: LoadContext | None = None) -> "Connection": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for Connection: {data}") @@ -70,7 +73,7 @@ def load(data: Any, context: LoadContext | None = None) -> "Connection": def load_kind(data: dict, context: LoadContext | None) -> "Connection": # load polymorphic Connection instance if data is not None and "kind" in data: - discriminator_value = str(data["kind"]).lower() + discriminator_value = str(data["kind"]) if discriminator_value == "reference": return ReferenceConnection.load(data, context) elif discriminator_value == "remote": @@ -85,9 +88,11 @@ def load_kind(data: dict, context: LoadContext | None) -> "Connection": return FoundryConnection.load(data, context) else: - raise ValueError(f"Unknown Connection discriminator value: {discriminator_value}") + # absorb unrecognized discriminator + return UnknownConnection.load(data, context) else: - raise ValueError("Missing Connection discriminator property: 'kind'") + # absorb missing discriminator + return UnknownConnection.load(data, context) def save(self, context: SaveContext | None = None) -> dict[str, Any]: """Save the Connection instance to a dictionary. @@ -101,7 +106,7 @@ def save(self, context: SaveContext | None = None) -> dict[str, Any]: if context is not None: obj = context.process_object(obj) - result: dict[str, Any] = {} + result: dict[str, Any] = copy.deepcopy(obj._raw) if obj.kind is not None: result["kind"] = obj.kind @@ -140,6 +145,26 @@ def to_json(self, context: SaveContext | None = None, indent: int = 2) -> str: return context.to_json(self.save(context), indent) +@dataclass +class UnknownConnection(Connection): + """Carries a Connection whose discriminator matches no known subtype. + + The unrecognized value stays on `kind` and every key the + schema does not declare is preserved verbatim, so an unknown Connection + survives a load/save round-trip unchanged. + + """ + + @staticmethod + def load(data: Any, context: LoadContext | None = None) -> "UnknownConnection": + instance = UnknownConnection() + instance._raw = copy.deepcopy(data) + instance._raw.pop("kind", None) + instance._raw.pop("authenticationMode", None) + instance._raw.pop("usageDescription", None) + return instance + + @dataclass class ReferenceConnection(Connection): """Connection configuration for AI services using named connections. @@ -171,8 +196,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ReferenceConnection" """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ReferenceConnection: {data}") @@ -270,8 +296,9 @@ def load(data: Any, context: LoadContext | None = None) -> "RemoteConnection": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for RemoteConnection: {data}") @@ -369,8 +396,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ApiKeyConnection": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ApiKeyConnection: {data}") @@ -465,8 +493,9 @@ def load(data: Any, context: LoadContext | None = None) -> "AnonymousConnection" """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for AnonymousConnection: {data}") @@ -558,7 +587,7 @@ class OAuthConnection(Connection): client_id: str = field(default="") client_secret: str = field(default="") token_url: str = field(default="") - scopes: list[str] = field(default_factory=list) + scopes: list[str] | None = None @staticmethod def load(data: Any, context: LoadContext | None = None) -> "OAuthConnection": @@ -571,8 +600,9 @@ def load(data: Any, context: LoadContext | None = None) -> "OAuthConnection": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for OAuthConnection: {data}") @@ -687,8 +717,9 @@ def load(data: Any, context: LoadContext | None = None) -> "FoundryConnection": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for FoundryConnection: {data}") diff --git a/runtime/python/prompty/prompty/model/connection/_DeviceAuthorization.py b/runtime/python/prompty/prompty/model/connection/_DeviceAuthorization.py index 8458c08ec..25e5c4f3e 100644 --- a/runtime/python/prompty/prompty/model/connection/_DeviceAuthorization.py +++ b/runtime/python/prompty/prompty/model/connection/_DeviceAuthorization.py @@ -51,8 +51,9 @@ def load(data: Any, context: LoadContext | None = None) -> "DeviceAuthorization" """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for DeviceAuthorization: {data}") diff --git a/runtime/python/prompty/prompty/model/connection/_OAuthToken.py b/runtime/python/prompty/prompty/model/connection/_OAuthToken.py index 4864eec0a..04e251a51 100644 --- a/runtime/python/prompty/prompty/model/connection/_OAuthToken.py +++ b/runtime/python/prompty/prompty/model/connection/_OAuthToken.py @@ -48,8 +48,9 @@ def load(data: Any, context: LoadContext | None = None) -> "OAuthToken": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for OAuthToken: {data}") diff --git a/runtime/python/prompty/prompty/model/conversation/_ContentPart.py b/runtime/python/prompty/prompty/model/conversation/_ContentPart.py index af4e90040..80da162d9 100644 --- a/runtime/python/prompty/prompty/model/conversation/_ContentPart.py +++ b/runtime/python/prompty/prompty/model/conversation/_ContentPart.py @@ -38,8 +38,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ContentPart": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ContentPart: {data}") @@ -57,7 +58,7 @@ def load(data: Any, context: LoadContext | None = None) -> "ContentPart": def load_kind(data: dict, context: LoadContext | None) -> "ContentPart": # load polymorphic ContentPart instance if data is not None and "kind" in data: - discriminator_value = str(data["kind"]).lower() + discriminator_value = str(data["kind"]) if discriminator_value == "text": return TextPart.load(data, context) elif discriminator_value == "image": @@ -68,7 +69,7 @@ def load_kind(data: dict, context: LoadContext | None) -> "ContentPart": return AudioPart.load(data, context) else: - raise ValueError(f"Unknown ContentPart discriminator value: {discriminator_value}") + raise ValueError(f"Unknown ContentPart discriminator field 'kind' value: {discriminator_value}") else: raise ValueError("Missing ContentPart discriminator property: 'kind'") @@ -147,8 +148,9 @@ def load(data: Any, context: LoadContext | None = None) -> "TextPart": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TextPart: {data}") @@ -245,8 +247,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ImagePart": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ImagePart: {data}") @@ -348,8 +351,9 @@ def load(data: Any, context: LoadContext | None = None) -> "FilePart": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for FilePart: {data}") @@ -447,8 +451,9 @@ def load(data: Any, context: LoadContext | None = None) -> "AudioPart": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for AudioPart: {data}") diff --git a/runtime/python/prompty/prompty/model/conversation/_Message.py b/runtime/python/prompty/prompty/model/conversation/_Message.py index 865583606..c81a676ac 100644 --- a/runtime/python/prompty/prompty/model/conversation/_Message.py +++ b/runtime/python/prompty/prompty/model/conversation/_Message.py @@ -26,7 +26,7 @@ class Message: parts : list[ContentPart] The content parts of the message metadata : dict[str, Any] - Optional metadata associated with the message + Optional metadata associated with the message. Values may be explicit null. """ _shorthand_property: ClassVar[str | None] = None @@ -46,8 +46,9 @@ def load(data: Any, context: LoadContext | None = None) -> "Message": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for Message: {data}") @@ -58,7 +59,7 @@ def load(data: Any, context: LoadContext | None = None) -> "Message": if data is not None and "role" in data: instance.role = data["role"] if data is not None and "parts" in data: - instance.parts = Message.load_parts(data["parts"], context) + instance.parts = Message.load_parts(data["parts"], context.at("parts")) if data is not None and "metadata" in data: instance.metadata = data["metadata"] if context is not None: @@ -67,25 +68,29 @@ def load(data: Any, context: LoadContext | None = None) -> "Message": @staticmethod def load_parts(data: dict | list, context: LoadContext | None) -> list[ContentPart]: + if context is None: + context = LoadContext(path="parts") if isinstance(data, dict): # convert simple named parts to list of ContentPart result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(ContentPart.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "kind": v}) - data = result - return [ContentPart.load(item, context) for item in data] + result.append(ContentPart.load({"name": k, "kind": v}, context.at(k))) + return result + return [ContentPart.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_parts(items: list[ContentPart], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/conversation/_ThreadMarker.py b/runtime/python/prompty/prompty/model/conversation/_ThreadMarker.py index fc0d905b5..87d239d34 100644 --- a/runtime/python/prompty/prompty/model/conversation/_ThreadMarker.py +++ b/runtime/python/prompty/prompty/model/conversation/_ThreadMarker.py @@ -43,8 +43,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ThreadMarker": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ThreadMarker: {data}") diff --git a/runtime/python/prompty/prompty/model/conversation/_ToolCall.py b/runtime/python/prompty/prompty/model/conversation/_ToolCall.py index 08e44bad5..9fb7c834c 100644 --- a/runtime/python/prompty/prompty/model/conversation/_ToolCall.py +++ b/runtime/python/prompty/prompty/model/conversation/_ToolCall.py @@ -43,8 +43,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolCall": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ToolCall: {data}") diff --git a/runtime/python/prompty/prompty/model/conversation/_ToolResult.py b/runtime/python/prompty/prompty/model/conversation/_ToolResult.py index 332bbd181..36be4d668 100644 --- a/runtime/python/prompty/prompty/model/conversation/_ToolResult.py +++ b/runtime/python/prompty/prompty/model/conversation/_ToolResult.py @@ -55,8 +55,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolResult": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ToolResult: {data}") @@ -65,7 +66,7 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolResult": instance = ToolResult() if data is not None and "parts" in data: - instance.parts = ToolResult.load_parts(data["parts"], context) + instance.parts = ToolResult.load_parts(data["parts"], context.at("parts")) if data is not None and "status" in data: instance.status = data["status"] if data is not None and "errorKind" in data: @@ -80,25 +81,29 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolResult": @staticmethod def load_parts(data: dict | list, context: LoadContext | None) -> list[ContentPart]: + if context is None: + context = LoadContext(path="parts") if isinstance(data, dict): # convert simple named parts to list of ContentPart result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(ContentPart.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "kind": v}) - data = result - return [ContentPart.load(item, context) for item in data] + result.append(ContentPart.load({"name": k, "kind": v}, context.at(k))) + return result + return [ContentPart.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_parts(items: list[ContentPart], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/core/_FileNotFoundError.py b/runtime/python/prompty/prompty/model/core/_FileNotFoundError.py index 47e95a796..40f2ef56c 100644 --- a/runtime/python/prompty/prompty/model/core/_FileNotFoundError.py +++ b/runtime/python/prompty/prompty/model/core/_FileNotFoundError.py @@ -40,8 +40,9 @@ def load(data: Any, context: LoadContext | None = None) -> "FileNotFoundError": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for FileNotFoundError: {data}") diff --git a/runtime/python/prompty/prompty/model/core/_InvokerError.py b/runtime/python/prompty/prompty/model/core/_InvokerError.py index 74bdd8710..78dd62d5b 100644 --- a/runtime/python/prompty/prompty/model/core/_InvokerError.py +++ b/runtime/python/prompty/prompty/model/core/_InvokerError.py @@ -44,8 +44,9 @@ def load(data: Any, context: LoadContext | None = None) -> "InvokerError": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for InvokerError: {data}") diff --git a/runtime/python/prompty/prompty/model/core/_Property.py b/runtime/python/prompty/prompty/model/core/_Property.py index 65dcf670f..8ed394097 100644 --- a/runtime/python/prompty/prompty/model/core/_Property.py +++ b/runtime/python/prompty/prompty/model/core/_Property.py @@ -5,6 +5,7 @@ # ANY EDITS WILL BE LOST ########################################## +import copy from dataclasses import dataclass, field from typing import Any, ClassVar @@ -49,7 +50,8 @@ class Property: nullable: bool | None = None default: Any | None = None example: Any | None = None - enum_values: list[Any] = field(default_factory=list) + enum_values: list[Any] | None = field(default_factory=list) + _raw: dict[str, Any] = field(default_factory=dict, init=False, repr=False) @staticmethod def load(data: Any, context: LoadContext | None = None) -> "Property": @@ -62,8 +64,9 @@ def load(data: Any, context: LoadContext | None = None) -> "Property": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) # handle alternate representations if isinstance(data, bool): @@ -117,6 +120,9 @@ def load(data: Any, context: LoadContext | None = None) -> "Property": instance.example = data["example"] if data is not None and "enumValues" in data: instance.enum_values = data["enumValues"] + + if type(instance) is Property: + instance._raw = copy.deepcopy(data) if context is not None: instance = context.process_output(instance) return instance @@ -125,7 +131,7 @@ def load(data: Any, context: LoadContext | None = None) -> "Property": def load_kind(data: dict, context: LoadContext | None) -> "Property": # load polymorphic Property instance if data is not None and "kind" in data: - discriminator_value = str(data["kind"]).lower() + discriminator_value = str(data["kind"]) if discriminator_value == "array": return ArrayProperty.load(data, context) elif discriminator_value == "object": @@ -152,7 +158,7 @@ def save(self, context: SaveContext | None = None) -> dict[str, Any]: if context is not None: obj = context.process_object(obj) - result: dict[str, Any] = {} + result: dict[str, Any] = copy.deepcopy(obj._raw) if obj.name is not None: result["name"] = obj.name @@ -210,14 +216,14 @@ class ArrayProperty(Property): ---------- kind : str - items : Property + items : Optional[Property] The type of items contained in the array """ _shorthand_property: ClassVar[str | None] = None kind: str = field(default="array") - items: Property = field(default_factory=Property) + items: Property | None = None @staticmethod def load(data: Any, context: LoadContext | None = None) -> "ArrayProperty": @@ -230,8 +236,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ArrayProperty": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ArrayProperty: {data}") @@ -242,7 +249,7 @@ def load(data: Any, context: LoadContext | None = None) -> "ArrayProperty": if data is not None and "kind" in data: instance.kind = data["kind"] if data is not None and "items" in data: - instance.items = Property.load(data["items"], context) + instance.items = Property.load(data["items"], context.at("items")) if context is not None: instance = context.process_output(instance) return instance @@ -323,8 +330,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ObjectProperty": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ObjectProperty: {data}") @@ -335,52 +343,71 @@ def load(data: Any, context: LoadContext | None = None) -> "ObjectProperty": if data is not None and "kind" in data: instance.kind = data["kind"] if data is not None and "properties" in data: - instance.properties = ObjectProperty.load_properties(data["properties"], context) + instance.properties = ObjectProperty.load_properties(data["properties"], context.at("properties")) if context is not None: instance = context.process_output(instance) return instance @staticmethod def load_properties(data: dict | list, context: LoadContext | None) -> list[Property]: + if context is None: + context = LoadContext(path="properties") if isinstance(data, dict): # convert simple named properties to list of Property result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Property.load({"name": k, **v}, context.at(k))) else: - # value is a scalar, use it as the primary property - result.append({"name": k, "kind": v}) - data = result - return [Property.load(item, context) for item in data] + # value is a scalar, infer the entry shape from its type + if isinstance(v, int) and not isinstance(v, bool): + shorthand = {"kind": "integer", "default": v} + elif isinstance(v, float): + shorthand = {"kind": "float", "default": v} + elif isinstance(v, str): + shorthand = {"kind": "string", "default": v} + elif isinstance(v, bool): + shorthand = {"kind": "boolean", "default": v} + else: + shorthand = {"default": v} + result.append(Property.load({"name": k, **shorthand}, context.at(k))) + return result + return [Property.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_properties(items: list[Property], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() + serialized = [dict(item.save(context)) for item in items] + for item_data in serialized: + if item_data.get("name") == "": + item_data.pop("name") + if context.collection_format == "array": - return [item.save(context) for item in items] + return serialized + + names: set[str] = set() + for item_data in serialized: + name = item_data.get("name") + if not isinstance(name, str) or not name or name in names: + return serialized + names.add(name) # Object format: use name as key result: dict[str, Any] = {} - for item in items: - item_data = item.save(context) - name = item_data.pop("name", None) - if name: - # Check if we can use shorthand (only primary property set) - if context.use_shorthand and hasattr(item, "_shorthand_property"): - shorthand_prop = item._shorthand_property - if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: - result[name] = item_data[shorthand_prop] - continue - result[name] = item_data - else: - # No name, fall back to array format for this item - if "_unnamed" not in result: - result["_unnamed"] = [] - result["_unnamed"].append(item_data) + for item, item_data in zip(items, serialized): + name = item_data.pop("name") + # Check if we can use shorthand (only primary property set) + if context.use_shorthand and hasattr(item, "_shorthand_property"): + shorthand_prop = item._shorthand_property + if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: + result[name] = item_data[shorthand_prop] + continue + result[name] = item_data return result def save(self, context: SaveContext | None = None) -> dict[str, Any]: @@ -453,8 +480,8 @@ class UnionProperty(Property): _shorthand_property: ClassVar[str | None] = None kind: str = field(default="union") - one_of: list[Property] = field(default_factory=list) - any_of: list[Property] = field(default_factory=list) + one_of: list[Property] | None = None + any_of: list[Property] | None = None @staticmethod def load(data: Any, context: LoadContext | None = None) -> "UnionProperty": @@ -467,8 +494,9 @@ def load(data: Any, context: LoadContext | None = None) -> "UnionProperty": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for UnionProperty: {data}") @@ -479,57 +507,85 @@ def load(data: Any, context: LoadContext | None = None) -> "UnionProperty": if data is not None and "kind" in data: instance.kind = data["kind"] if data is not None and "oneOf" in data: - instance.one_of = UnionProperty.load_one_of(data["oneOf"], context) + instance.one_of = UnionProperty.load_one_of(data["oneOf"], context.at("oneOf")) if data is not None and "anyOf" in data: - instance.any_of = UnionProperty.load_any_of(data["anyOf"], context) + instance.any_of = UnionProperty.load_any_of(data["anyOf"], context.at("anyOf")) if context is not None: instance = context.process_output(instance) return instance @staticmethod def load_one_of(data: dict | list, context: LoadContext | None) -> list[Property]: + if context is None: + context = LoadContext(path="oneOf") if isinstance(data, dict): # convert simple named oneOf to list of Property result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Property.load({"name": k, **v}, context.at(k))) else: - # value is a scalar, use it as the primary property - result.append({"name": k, "kind": v}) - data = result - return [Property.load(item, context) for item in data] + # value is a scalar, infer the entry shape from its type + if isinstance(v, int) and not isinstance(v, bool): + shorthand = {"kind": "integer", "default": v} + elif isinstance(v, float): + shorthand = {"kind": "float", "default": v} + elif isinstance(v, str): + shorthand = {"kind": "string", "default": v} + elif isinstance(v, bool): + shorthand = {"kind": "boolean", "default": v} + else: + shorthand = {"default": v} + result.append(Property.load({"name": k, **shorthand}, context.at(k))) + return result + return [Property.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_one_of(items: list[Property], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] @staticmethod def load_any_of(data: dict | list, context: LoadContext | None) -> list[Property]: + if context is None: + context = LoadContext(path="anyOf") if isinstance(data, dict): # convert simple named anyOf to list of Property result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Property.load({"name": k, **v}, context.at(k))) else: - # value is a scalar, use it as the primary property - result.append({"name": k, "kind": v}) - data = result - return [Property.load(item, context) for item in data] + # value is a scalar, infer the entry shape from its type + if isinstance(v, int) and not isinstance(v, bool): + shorthand = {"kind": "integer", "default": v} + elif isinstance(v, float): + shorthand = {"kind": "float", "default": v} + elif isinstance(v, str): + shorthand = {"kind": "string", "default": v} + elif isinstance(v, bool): + shorthand = {"kind": "boolean", "default": v} + else: + shorthand = {"default": v} + result.append(Property.load({"name": k, **shorthand}, context.at(k))) + return result + return [Property.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_any_of(items: list[Property], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/core/_ValidationError.py b/runtime/python/prompty/prompty/model/core/_ValidationError.py index 9efc65407..bdade01c4 100644 --- a/runtime/python/prompty/prompty/model/core/_ValidationError.py +++ b/runtime/python/prompty/prompty/model/core/_ValidationError.py @@ -43,8 +43,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ValidationError": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ValidationError: {data}") diff --git a/runtime/python/prompty/prompty/model/core/_ValidationResult.py b/runtime/python/prompty/prompty/model/core/_ValidationResult.py index d28edd55a..7613a51c9 100644 --- a/runtime/python/prompty/prompty/model/core/_ValidationResult.py +++ b/runtime/python/prompty/prompty/model/core/_ValidationResult.py @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ValidationResult": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ValidationResult: {data}") @@ -54,32 +55,36 @@ def load(data: Any, context: LoadContext | None = None) -> "ValidationResult": if data is not None and "valid" in data: instance.valid = data["valid"] if data is not None and "errors" in data: - instance.errors = ValidationResult.load_errors(data["errors"], context) + instance.errors = ValidationResult.load_errors(data["errors"], context.at("errors")) if context is not None: instance = context.process_output(instance) return instance @staticmethod def load_errors(data: dict | list, context: LoadContext | None) -> list[ValidationError]: + if context is None: + context = LoadContext(path="errors") if isinstance(data, dict): # convert simple named errors to list of ValidationError result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(ValidationError.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "message": v}) - data = result - return [ValidationError.load(item, context) for item in data] + result.append(ValidationError.load({"name": k, "message": v}, context.at(k))) + return result + return [ValidationError.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_errors(items: list[ValidationError], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/events/_Checkpoint.py b/runtime/python/prompty/prompty/model/events/_Checkpoint.py index 7977d5ec7..d55ef9ceb 100644 --- a/runtime/python/prompty/prompty/model/events/_Checkpoint.py +++ b/runtime/python/prompty/prompty/model/events/_Checkpoint.py @@ -67,8 +67,9 @@ def load(data: Any, context: LoadContext | None = None) -> "Checkpoint": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for Checkpoint: {data}") @@ -97,7 +98,7 @@ def load(data: Any, context: LoadContext | None = None) -> "Checkpoint": if data is not None and "createdAt" in data: instance.created_at = data["createdAt"] if data is not None and "redaction" in data: - instance.redaction = RedactionMetadata.load(data["redaction"], context) + instance.redaction = RedactionMetadata.load(data["redaction"], context.at("redaction")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/events/_CompactionCompletePayload.py b/runtime/python/prompty/prompty/model/events/_CompactionCompletePayload.py index ae3877850..bec28b1ee 100644 --- a/runtime/python/prompty/prompty/model/events/_CompactionCompletePayload.py +++ b/runtime/python/prompty/prompty/model/events/_CompactionCompletePayload.py @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "CompactionCompletePa """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for CompactionCompletePayload: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_CompactionFailedPayload.py b/runtime/python/prompty/prompty/model/events/_CompactionFailedPayload.py index e7d6f2001..cd07ad4f2 100644 --- a/runtime/python/prompty/prompty/model/events/_CompactionFailedPayload.py +++ b/runtime/python/prompty/prompty/model/events/_CompactionFailedPayload.py @@ -36,8 +36,9 @@ def load(data: Any, context: LoadContext | None = None) -> "CompactionFailedPayl """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for CompactionFailedPayload: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_CompactionStartPayload.py b/runtime/python/prompty/prompty/model/events/_CompactionStartPayload.py index 574b01b81..47f7df14d 100644 --- a/runtime/python/prompty/prompty/model/events/_CompactionStartPayload.py +++ b/runtime/python/prompty/prompty/model/events/_CompactionStartPayload.py @@ -36,8 +36,9 @@ def load(data: Any, context: LoadContext | None = None) -> "CompactionStartPaylo """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for CompactionStartPayload: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_DoneEventPayload.py b/runtime/python/prompty/prompty/model/events/_DoneEventPayload.py index cb5772233..b032aad83 100644 --- a/runtime/python/prompty/prompty/model/events/_DoneEventPayload.py +++ b/runtime/python/prompty/prompty/model/events/_DoneEventPayload.py @@ -40,8 +40,9 @@ def load(data: Any, context: LoadContext | None = None) -> "DoneEventPayload": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for DoneEventPayload: {data}") @@ -52,32 +53,36 @@ def load(data: Any, context: LoadContext | None = None) -> "DoneEventPayload": if data is not None and "response" in data: instance.response = data["response"] if data is not None and "messages" in data: - instance.messages = DoneEventPayload.load_messages(data["messages"], context) + instance.messages = DoneEventPayload.load_messages(data["messages"], context.at("messages")) if context is not None: instance = context.process_output(instance) return instance @staticmethod def load_messages(data: dict | list, context: LoadContext | None) -> list[Message]: + if context is None: + context = LoadContext(path="messages") if isinstance(data, dict): # convert simple named messages to list of Message result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Message.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "role": v}) - data = result - return [Message.load(item, context) for item in data] + result.append(Message.load({"name": k, "role": v}, context.at(k))) + return result + return [Message.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_messages(items: list[Message], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/events/_ErrorEventPayload.py b/runtime/python/prompty/prompty/model/events/_ErrorEventPayload.py index 8524888cf..8dcd455e5 100644 --- a/runtime/python/prompty/prompty/model/events/_ErrorEventPayload.py +++ b/runtime/python/prompty/prompty/model/events/_ErrorEventPayload.py @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ErrorEventPayload": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ErrorEventPayload: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_HarnessContext.py b/runtime/python/prompty/prompty/model/events/_HarnessContext.py index 5480523f5..a21420e7b 100644 --- a/runtime/python/prompty/prompty/model/events/_HarnessContext.py +++ b/runtime/python/prompty/prompty/model/events/_HarnessContext.py @@ -44,8 +44,9 @@ def load(data: Any, context: LoadContext | None = None) -> "HarnessContext": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for HarnessContext: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_HookEndPayload.py b/runtime/python/prompty/prompty/model/events/_HookEndPayload.py index f913b8709..0ae106a9d 100644 --- a/runtime/python/prompty/prompty/model/events/_HookEndPayload.py +++ b/runtime/python/prompty/prompty/model/events/_HookEndPayload.py @@ -60,8 +60,9 @@ def load(data: Any, context: LoadContext | None = None) -> "HookEndPayload": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for HookEndPayload: {data}") @@ -84,7 +85,7 @@ def load(data: Any, context: LoadContext | None = None) -> "HookEndPayload": if data is not None and "error" in data: instance.error = data["error"] if data is not None and "redaction" in data: - instance.redaction = RedactionMetadata.load(data["redaction"], context) + instance.redaction = RedactionMetadata.load(data["redaction"], context.at("redaction")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/events/_HookStartPayload.py b/runtime/python/prompty/prompty/model/events/_HookStartPayload.py index b1e6171bc..f04f056dc 100644 --- a/runtime/python/prompty/prompty/model/events/_HookStartPayload.py +++ b/runtime/python/prompty/prompty/model/events/_HookStartPayload.py @@ -51,8 +51,9 @@ def load(data: Any, context: LoadContext | None = None) -> "HookStartPayload": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for HookStartPayload: {data}") @@ -69,7 +70,7 @@ def load(data: Any, context: LoadContext | None = None) -> "HookStartPayload": if data is not None and "input" in data: instance.input = data["input"] if data is not None and "redaction" in data: - instance.redaction = RedactionMetadata.load(data["redaction"], context) + instance.redaction = RedactionMetadata.load(data["redaction"], context.at("redaction")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/events/_HostToolRequest.py b/runtime/python/prompty/prompty/model/events/_HostToolRequest.py index 24f9c7174..15c450a03 100644 --- a/runtime/python/prompty/prompty/model/events/_HostToolRequest.py +++ b/runtime/python/prompty/prompty/model/events/_HostToolRequest.py @@ -24,7 +24,7 @@ class HostToolRequest: tool_name : str Name of the host tool being executed arguments : Optional[dict[str, Any]] - Tool arguments after host-side sanitization + Tool arguments after host-side sanitization. Values may be explicit null. working_directory : Optional[str] Working directory or execution scope for the tool """ @@ -48,8 +48,9 @@ def load(data: Any, context: LoadContext | None = None) -> "HostToolRequest": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for HostToolRequest: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_HostToolResult.py b/runtime/python/prompty/prompty/model/events/_HostToolResult.py index c54872f5d..502b20e5d 100644 --- a/runtime/python/prompty/prompty/model/events/_HostToolResult.py +++ b/runtime/python/prompty/prompty/model/events/_HostToolResult.py @@ -60,8 +60,9 @@ def load(data: Any, context: LoadContext | None = None) -> "HostToolResult": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for HostToolResult: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_LlmCompletePayload.py b/runtime/python/prompty/prompty/model/events/_LlmCompletePayload.py index d59608f44..95a35c695 100644 --- a/runtime/python/prompty/prompty/model/events/_LlmCompletePayload.py +++ b/runtime/python/prompty/prompty/model/events/_LlmCompletePayload.py @@ -46,8 +46,9 @@ def load(data: Any, context: LoadContext | None = None) -> "LlmCompletePayload": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for LlmCompletePayload: {data}") @@ -60,7 +61,7 @@ def load(data: Any, context: LoadContext | None = None) -> "LlmCompletePayload": if data is not None and "serviceRequestId" in data: instance.service_request_id = data["serviceRequestId"] if data is not None and "usage" in data: - instance.usage = TokenUsage.load(data["usage"], context) + instance.usage = TokenUsage.load(data["usage"], context.at("usage")) if data is not None and "durationMs" in data: instance.duration_ms = data["durationMs"] if context is not None: diff --git a/runtime/python/prompty/prompty/model/events/_LlmStartPayload.py b/runtime/python/prompty/prompty/model/events/_LlmStartPayload.py index 719dd9351..ac9a0fb9e 100644 --- a/runtime/python/prompty/prompty/model/events/_LlmStartPayload.py +++ b/runtime/python/prompty/prompty/model/events/_LlmStartPayload.py @@ -45,8 +45,9 @@ def load(data: Any, context: LoadContext | None = None) -> "LlmStartPayload": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for LlmStartPayload: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_MessagesUpdatedPayload.py b/runtime/python/prompty/prompty/model/events/_MessagesUpdatedPayload.py index 42f8d6124..9f3584b28 100644 --- a/runtime/python/prompty/prompty/model/events/_MessagesUpdatedPayload.py +++ b/runtime/python/prompty/prompty/model/events/_MessagesUpdatedPayload.py @@ -5,7 +5,7 @@ # ANY EDITS WILL BE LOST ########################################## -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, ClassVar from .._context import LoadContext, SaveContext @@ -30,9 +30,9 @@ class MessagesUpdatedPayload: _shorthand_property: ClassVar[str | None] = None - messages: list[Message] = field(default_factory=list) + messages: list[Message] | None = None reason: str | None = None - appended: list[Message] = field(default_factory=list) + appended: list[Message] | None = None removed: int | None = None @staticmethod @@ -46,8 +46,9 @@ def load(data: Any, context: LoadContext | None = None) -> "MessagesUpdatedPaylo """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for MessagesUpdatedPayload: {data}") @@ -56,11 +57,11 @@ def load(data: Any, context: LoadContext | None = None) -> "MessagesUpdatedPaylo instance = MessagesUpdatedPayload() if data is not None and "messages" in data: - instance.messages = MessagesUpdatedPayload.load_messages(data["messages"], context) + instance.messages = MessagesUpdatedPayload.load_messages(data["messages"], context.at("messages")) if data is not None and "reason" in data: instance.reason = data["reason"] if data is not None and "appended" in data: - instance.appended = MessagesUpdatedPayload.load_appended(data["appended"], context) + instance.appended = MessagesUpdatedPayload.load_appended(data["appended"], context.at("appended")) if data is not None and "removed" in data: instance.removed = data["removed"] if context is not None: @@ -69,48 +70,56 @@ def load(data: Any, context: LoadContext | None = None) -> "MessagesUpdatedPaylo @staticmethod def load_messages(data: dict | list, context: LoadContext | None) -> list[Message]: + if context is None: + context = LoadContext(path="messages") if isinstance(data, dict): # convert simple named messages to list of Message result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Message.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "role": v}) - data = result - return [Message.load(item, context) for item in data] + result.append(Message.load({"name": k, "role": v}, context.at(k))) + return result + return [Message.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_messages(items: list[Message], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] @staticmethod def load_appended(data: dict | list, context: LoadContext | None) -> list[Message]: + if context is None: + context = LoadContext(path="appended") if isinstance(data, dict): # convert simple named appended to list of Message result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Message.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "role": v}) - data = result - return [Message.load(item, context) for item in data] + result.append(Message.load({"name": k, "role": v}, context.at(k))) + return result + return [Message.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_appended(items: list[Message], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/events/_PermissionCompletedPayload.py b/runtime/python/prompty/prompty/model/events/_PermissionCompletedPayload.py index 9215718af..5e69a897f 100644 --- a/runtime/python/prompty/prompty/model/events/_PermissionCompletedPayload.py +++ b/runtime/python/prompty/prompty/model/events/_PermissionCompletedPayload.py @@ -55,8 +55,9 @@ def load(data: Any, context: LoadContext | None = None) -> "PermissionCompletedP """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for PermissionCompletedPayload: {data}") @@ -77,7 +78,7 @@ def load(data: Any, context: LoadContext | None = None) -> "PermissionCompletedP if data is not None and "result" in data: instance.result = data["result"] if data is not None and "redaction" in data: - instance.redaction = RedactionMetadata.load(data["redaction"], context) + instance.redaction = RedactionMetadata.load(data["redaction"], context.at("redaction")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/events/_PermissionDecision.py b/runtime/python/prompty/prompty/model/events/_PermissionDecision.py index aaa61632d..69910d07a 100644 --- a/runtime/python/prompty/prompty/model/events/_PermissionDecision.py +++ b/runtime/python/prompty/prompty/model/events/_PermissionDecision.py @@ -51,8 +51,9 @@ def load(data: Any, context: LoadContext | None = None) -> "PermissionDecision": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for PermissionDecision: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_PermissionRequest.py b/runtime/python/prompty/prompty/model/events/_PermissionRequest.py index 85963dc05..9c003f0f6 100644 --- a/runtime/python/prompty/prompty/model/events/_PermissionRequest.py +++ b/runtime/python/prompty/prompty/model/events/_PermissionRequest.py @@ -55,8 +55,9 @@ def load(data: Any, context: LoadContext | None = None) -> "PermissionRequest": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for PermissionRequest: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_PermissionRequestedPayload.py b/runtime/python/prompty/prompty/model/events/_PermissionRequestedPayload.py index 6125d53b4..162fe97bb 100644 --- a/runtime/python/prompty/prompty/model/events/_PermissionRequestedPayload.py +++ b/runtime/python/prompty/prompty/model/events/_PermissionRequestedPayload.py @@ -58,8 +58,9 @@ def load(data: Any, context: LoadContext | None = None) -> "PermissionRequestedP """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for PermissionRequestedPayload: {data}") @@ -82,7 +83,7 @@ def load(data: Any, context: LoadContext | None = None) -> "PermissionRequestedP if data is not None and "policy" in data: instance.policy = data["policy"] if data is not None and "redaction" in data: - instance.redaction = RedactionMetadata.load(data["redaction"], context) + instance.redaction = RedactionMetadata.load(data["redaction"], context.at("redaction")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/events/_RedactedField.py b/runtime/python/prompty/prompty/model/events/_RedactedField.py index 59d929e64..3a958ff3b 100644 --- a/runtime/python/prompty/prompty/model/events/_RedactedField.py +++ b/runtime/python/prompty/prompty/model/events/_RedactedField.py @@ -44,8 +44,9 @@ def load(data: Any, context: LoadContext | None = None) -> "RedactedField": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for RedactedField: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_RedactionMetadata.py b/runtime/python/prompty/prompty/model/events/_RedactionMetadata.py index 0c325b015..7dc1e3bec 100644 --- a/runtime/python/prompty/prompty/model/events/_RedactionMetadata.py +++ b/runtime/python/prompty/prompty/model/events/_RedactionMetadata.py @@ -5,7 +5,7 @@ # ANY EDITS WILL BE LOST ########################################## -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, ClassVar from .._context import LoadContext, SaveContext @@ -29,7 +29,7 @@ class RedactionMetadata: _shorthand_property: ClassVar[str | None] = None sanitized: bool | None = None - fields: list[RedactedField] = field(default_factory=list) + fields: list[RedactedField] | None = None policy: str | None = None @staticmethod @@ -43,8 +43,9 @@ def load(data: Any, context: LoadContext | None = None) -> "RedactionMetadata": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for RedactionMetadata: {data}") @@ -55,7 +56,7 @@ def load(data: Any, context: LoadContext | None = None) -> "RedactionMetadata": if data is not None and "sanitized" in data: instance.sanitized = data["sanitized"] if data is not None and "fields" in data: - instance.fields = RedactionMetadata.load_fields(data["fields"], context) + instance.fields = RedactionMetadata.load_fields(data["fields"], context.at("fields")) if data is not None and "policy" in data: instance.policy = data["policy"] if context is not None: @@ -64,25 +65,29 @@ def load(data: Any, context: LoadContext | None = None) -> "RedactionMetadata": @staticmethod def load_fields(data: dict | list, context: LoadContext | None) -> list[RedactedField]: + if context is None: + context = LoadContext(path="fields") if isinstance(data, dict): # convert simple named fields to list of RedactedField result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(RedactedField.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "path": v}) - data = result - return [RedactedField.load(item, context) for item in data] + result.append(RedactedField.load({"name": k, "path": v}, context.at(k))) + return result + return [RedactedField.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_fields(items: list[RedactedField], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/events/_RetryPayload.py b/runtime/python/prompty/prompty/model/events/_RetryPayload.py index 215706aa9..95da5d21a 100644 --- a/runtime/python/prompty/prompty/model/events/_RetryPayload.py +++ b/runtime/python/prompty/prompty/model/events/_RetryPayload.py @@ -48,8 +48,9 @@ def load(data: Any, context: LoadContext | None = None) -> "RetryPayload": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for RetryPayload: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_SessionEndPayload.py b/runtime/python/prompty/prompty/model/events/_SessionEndPayload.py index aec2e435c..26841c0eb 100644 --- a/runtime/python/prompty/prompty/model/events/_SessionEndPayload.py +++ b/runtime/python/prompty/prompty/model/events/_SessionEndPayload.py @@ -47,8 +47,9 @@ def load(data: Any, context: LoadContext | None = None) -> "SessionEndPayload": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for SessionEndPayload: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_SessionEvent.py b/runtime/python/prompty/prompty/model/events/_SessionEvent.py index 663e64b3a..9ea046fd1 100644 --- a/runtime/python/prompty/prompty/model/events/_SessionEvent.py +++ b/runtime/python/prompty/prompty/model/events/_SessionEvent.py @@ -43,7 +43,7 @@ class SessionEvent: span_id : Optional[str] Trace span identifier associated with this event payload : dict[str, Any] - Event-specific payload. Use the typed payload model matching 'type'. + Event-specific payload. Values may be explicit null. Use the typed payload model matching 'type'. redaction : Optional[RedactionMetadata] Redaction state for sensitive payload fields """ @@ -71,8 +71,9 @@ def load(data: Any, context: LoadContext | None = None) -> "SessionEvent": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for SessionEvent: {data}") @@ -97,7 +98,7 @@ def load(data: Any, context: LoadContext | None = None) -> "SessionEvent": if data is not None and "payload" in data: instance.payload = data["payload"] if data is not None and "redaction" in data: - instance.redaction = RedactionMetadata.load(data["redaction"], context) + instance.redaction = RedactionMetadata.load(data["redaction"], context.at("redaction")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/events/_SessionFileRef.py b/runtime/python/prompty/prompty/model/events/_SessionFileRef.py index b932f69f6..8ab57df04 100644 --- a/runtime/python/prompty/prompty/model/events/_SessionFileRef.py +++ b/runtime/python/prompty/prompty/model/events/_SessionFileRef.py @@ -48,8 +48,9 @@ def load(data: Any, context: LoadContext | None = None) -> "SessionFileRef": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for SessionFileRef: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_SessionRef.py b/runtime/python/prompty/prompty/model/events/_SessionRef.py index 9c7d22f1e..69273baee 100644 --- a/runtime/python/prompty/prompty/model/events/_SessionRef.py +++ b/runtime/python/prompty/prompty/model/events/_SessionRef.py @@ -48,8 +48,9 @@ def load(data: Any, context: LoadContext | None = None) -> "SessionRef": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for SessionRef: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_SessionStartPayload.py b/runtime/python/prompty/prompty/model/events/_SessionStartPayload.py index bc13ed0f0..4c302e02e 100644 --- a/runtime/python/prompty/prompty/model/events/_SessionStartPayload.py +++ b/runtime/python/prompty/prompty/model/events/_SessionStartPayload.py @@ -61,8 +61,9 @@ def load(data: Any, context: LoadContext | None = None) -> "SessionStartPayload" """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for SessionStartPayload: {data}") @@ -87,7 +88,7 @@ def load(data: Any, context: LoadContext | None = None) -> "SessionStartPayload" if data is not None and "reasoningEffort" in data: instance.reasoning_effort = data["reasoningEffort"] if data is not None and "context" in data: - instance.context = HarnessContext.load(data["context"], context) + instance.context = HarnessContext.load(data["context"], context.at("context")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/events/_SessionSummary.py b/runtime/python/prompty/prompty/model/events/_SessionSummary.py index f3d7bb58f..649d76105 100644 --- a/runtime/python/prompty/prompty/model/events/_SessionSummary.py +++ b/runtime/python/prompty/prompty/model/events/_SessionSummary.py @@ -54,8 +54,9 @@ def load(data: Any, context: LoadContext | None = None) -> "SessionSummary": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for SessionSummary: {data}") @@ -72,7 +73,7 @@ def load(data: Any, context: LoadContext | None = None) -> "SessionSummary": if data is not None and "checkpoints" in data: instance.checkpoints = data["checkpoints"] if data is not None and "usage" in data: - instance.usage = TokenUsage.load(data["usage"], context) + instance.usage = TokenUsage.load(data["usage"], context.at("usage")) if data is not None and "durationMs" in data: instance.duration_ms = data["durationMs"] if context is not None: diff --git a/runtime/python/prompty/prompty/model/events/_SessionTrace.py b/runtime/python/prompty/prompty/model/events/_SessionTrace.py index 70bae77e8..cf98bf81c 100644 --- a/runtime/python/prompty/prompty/model/events/_SessionTrace.py +++ b/runtime/python/prompty/prompty/model/events/_SessionTrace.py @@ -55,11 +55,11 @@ class SessionTrace: prompty_version: str | None = None session_id: str | None = None events: list[SessionEvent] = field(default_factory=list) - turns: list[TurnTrace] = field(default_factory=list) - checkpoints: list[Checkpoint] = field(default_factory=list) - trajectory: list[TrajectoryEvent] = field(default_factory=list) - files: list[SessionFileRef] = field(default_factory=list) - refs: list[SessionRef] = field(default_factory=list) + turns: list[TurnTrace] | None = None + checkpoints: list[Checkpoint] | None = None + trajectory: list[TrajectoryEvent] | None = None + files: list[SessionFileRef] | None = None + refs: list[SessionRef] | None = None summary: SessionSummary | None = None @staticmethod @@ -73,8 +73,9 @@ def load(data: Any, context: LoadContext | None = None) -> "SessionTrace": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for SessionTrace: {data}") @@ -91,106 +92,122 @@ def load(data: Any, context: LoadContext | None = None) -> "SessionTrace": if data is not None and "sessionId" in data: instance.session_id = data["sessionId"] if data is not None and "events" in data: - instance.events = SessionTrace.load_events(data["events"], context) + instance.events = SessionTrace.load_events(data["events"], context.at("events")) if data is not None and "turns" in data: - instance.turns = SessionTrace.load_turns(data["turns"], context) + instance.turns = SessionTrace.load_turns(data["turns"], context.at("turns")) if data is not None and "checkpoints" in data: - instance.checkpoints = SessionTrace.load_checkpoints(data["checkpoints"], context) + instance.checkpoints = SessionTrace.load_checkpoints(data["checkpoints"], context.at("checkpoints")) if data is not None and "trajectory" in data: - instance.trajectory = SessionTrace.load_trajectory(data["trajectory"], context) + instance.trajectory = SessionTrace.load_trajectory(data["trajectory"], context.at("trajectory")) if data is not None and "files" in data: - instance.files = SessionTrace.load_files(data["files"], context) + instance.files = SessionTrace.load_files(data["files"], context.at("files")) if data is not None and "refs" in data: - instance.refs = SessionTrace.load_refs(data["refs"], context) + instance.refs = SessionTrace.load_refs(data["refs"], context.at("refs")) if data is not None and "summary" in data: - instance.summary = SessionSummary.load(data["summary"], context) + instance.summary = SessionSummary.load(data["summary"], context.at("summary")) if context is not None: instance = context.process_output(instance) return instance @staticmethod def load_events(data: dict | list, context: LoadContext | None) -> list[SessionEvent]: + if context is None: + context = LoadContext(path="events") if isinstance(data, dict): # convert simple named events to list of SessionEvent result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(SessionEvent.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "id": v}) - data = result - return [SessionEvent.load(item, context) for item in data] + result.append(SessionEvent.load({"name": k, "id": v}, context.at(k))) + return result + return [SessionEvent.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_events(items: list[SessionEvent], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] @staticmethod def load_turns(data: dict | list, context: LoadContext | None) -> list[TurnTrace]: + if context is None: + context = LoadContext(path="turns") if isinstance(data, dict): # convert simple named turns to list of TurnTrace result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(TurnTrace.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "version": v}) - data = result - return [TurnTrace.load(item, context) for item in data] + result.append(TurnTrace.load({"name": k, "version": v}, context.at(k))) + return result + return [TurnTrace.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_turns(items: list[TurnTrace], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] @staticmethod def load_checkpoints(data: dict | list, context: LoadContext | None) -> list[Checkpoint]: + if context is None: + context = LoadContext(path="checkpoints") if isinstance(data, dict): # convert simple named checkpoints to list of Checkpoint result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Checkpoint.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "id": v}) - data = result - return [Checkpoint.load(item, context) for item in data] + result.append(Checkpoint.load({"name": k, "id": v}, context.at(k))) + return result + return [Checkpoint.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_checkpoints(items: list[Checkpoint], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] @staticmethod def load_trajectory(data: dict | list, context: LoadContext | None) -> list[TrajectoryEvent]: + if context is None: + context = LoadContext(path="trajectory") if isinstance(data, dict): # convert simple named trajectory to list of TrajectoryEvent result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(TrajectoryEvent.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "id": v}) - data = result - return [TrajectoryEvent.load(item, context) for item in data] + result.append(TrajectoryEvent.load({"name": k, "id": v}, context.at(k))) + return result + return [TrajectoryEvent.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_trajectory( @@ -199,53 +216,61 @@ def save_trajectory( if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] @staticmethod def load_files(data: dict | list, context: LoadContext | None) -> list[SessionFileRef]: + if context is None: + context = LoadContext(path="files") if isinstance(data, dict): # convert simple named files to list of SessionFileRef result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(SessionFileRef.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "sessionId": v}) - data = result - return [SessionFileRef.load(item, context) for item in data] + result.append(SessionFileRef.load({"name": k, "sessionId": v}, context.at(k))) + return result + return [SessionFileRef.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_files(items: list[SessionFileRef], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] @staticmethod def load_refs(data: dict | list, context: LoadContext | None) -> list[SessionRef]: + if context is None: + context = LoadContext(path="refs") if isinstance(data, dict): # convert simple named refs to list of SessionRef result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(SessionRef.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "sessionId": v}) - data = result - return [SessionRef.load(item, context) for item in data] + result.append(SessionRef.load({"name": k, "sessionId": v}, context.at(k))) + return result + return [SessionRef.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_refs(items: list[SessionRef], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/events/_SessionWarningPayload.py b/runtime/python/prompty/prompty/model/events/_SessionWarningPayload.py index 6b597c353..c07198e90 100644 --- a/runtime/python/prompty/prompty/model/events/_SessionWarningPayload.py +++ b/runtime/python/prompty/prompty/model/events/_SessionWarningPayload.py @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "SessionWarningPayloa """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for SessionWarningPayload: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_StatusEventPayload.py b/runtime/python/prompty/prompty/model/events/_StatusEventPayload.py index ad7bf9b83..f6a993e7b 100644 --- a/runtime/python/prompty/prompty/model/events/_StatusEventPayload.py +++ b/runtime/python/prompty/prompty/model/events/_StatusEventPayload.py @@ -36,8 +36,9 @@ def load(data: Any, context: LoadContext | None = None) -> "StatusEventPayload": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for StatusEventPayload: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_StreamChunk.py b/runtime/python/prompty/prompty/model/events/_StreamChunk.py index 4a11e3f7c..c9009b16d 100644 --- a/runtime/python/prompty/prompty/model/events/_StreamChunk.py +++ b/runtime/python/prompty/prompty/model/events/_StreamChunk.py @@ -40,8 +40,9 @@ def load(data: Any, context: LoadContext | None = None) -> "StreamChunk": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for StreamChunk: {data}") @@ -59,7 +60,7 @@ def load(data: Any, context: LoadContext | None = None) -> "StreamChunk": def load_kind(data: dict, context: LoadContext | None) -> "StreamChunk": # load polymorphic StreamChunk instance if data is not None and "kind" in data: - discriminator_value = str(data["kind"]).lower() + discriminator_value = str(data["kind"]) if discriminator_value == "text": return TextChunk.load(data, context) elif discriminator_value == "thinking": @@ -72,7 +73,7 @@ def load_kind(data: dict, context: LoadContext | None) -> "StreamChunk": return ErrorChunk.load(data, context) else: - raise ValueError(f"Unknown StreamChunk discriminator value: {discriminator_value}") + raise ValueError(f"Unknown StreamChunk discriminator field 'kind' value: {discriminator_value}") else: raise ValueError("Missing StreamChunk discriminator property: 'kind'") @@ -151,8 +152,9 @@ def load(data: Any, context: LoadContext | None = None) -> "TextChunk": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TextChunk: {data}") @@ -243,8 +245,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ThinkingChunk": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ThinkingChunk: {data}") @@ -335,11 +338,14 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolChunk": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ToolChunk: {data}") + if "toolCall" not in data or data["toolCall"] is None: + raise ValueError(f"{context.at('toolCall').path}: missing required field") # create new instance instance = ToolChunk() @@ -347,7 +353,7 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolChunk": if data is not None and "kind" in data: instance.kind = data["kind"] if data is not None and "toolCall" in data: - instance.tool_call = ToolCall.load(data["toolCall"], context) + instance.tool_call = ToolCall.load(data["toolCall"], context.at("toolCall")) if context is not None: instance = context.process_output(instance) return instance @@ -427,11 +433,14 @@ def load(data: Any, context: LoadContext | None = None) -> "UsageChunk": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for UsageChunk: {data}") + if "usage" not in data or data["usage"] is None: + raise ValueError(f"{context.at('usage').path}: missing required field") # create new instance instance = UsageChunk() @@ -439,7 +448,7 @@ def load(data: Any, context: LoadContext | None = None) -> "UsageChunk": if data is not None and "kind" in data: instance.kind = data["kind"] if data is not None and "usage" in data: - instance.usage = InvocationUsage.load(data["usage"], context) + instance.usage = InvocationUsage.load(data["usage"], context.at("usage")) if context is not None: instance = context.process_output(instance) return instance @@ -519,8 +528,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ErrorChunk": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ErrorChunk: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_ThinkingEventPayload.py b/runtime/python/prompty/prompty/model/events/_ThinkingEventPayload.py index 0639fcafc..5daa39fde 100644 --- a/runtime/python/prompty/prompty/model/events/_ThinkingEventPayload.py +++ b/runtime/python/prompty/prompty/model/events/_ThinkingEventPayload.py @@ -36,8 +36,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ThinkingEventPayload """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ThinkingEventPayload: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_TokenEventPayload.py b/runtime/python/prompty/prompty/model/events/_TokenEventPayload.py index e1ab3921b..f16f916b8 100644 --- a/runtime/python/prompty/prompty/model/events/_TokenEventPayload.py +++ b/runtime/python/prompty/prompty/model/events/_TokenEventPayload.py @@ -36,8 +36,9 @@ def load(data: Any, context: LoadContext | None = None) -> "TokenEventPayload": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TokenEventPayload: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_ToolCallCompletePayload.py b/runtime/python/prompty/prompty/model/events/_ToolCallCompletePayload.py index 736fd44fe..88ff3449e 100644 --- a/runtime/python/prompty/prompty/model/events/_ToolCallCompletePayload.py +++ b/runtime/python/prompty/prompty/model/events/_ToolCallCompletePayload.py @@ -52,8 +52,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolCallCompletePayl """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ToolCallCompletePayload: {data}") @@ -68,7 +69,7 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolCallCompletePayl if data is not None and "success" in data: instance.success = data["success"] if data is not None and "result" in data: - instance.result = ToolResult.load(data["result"], context) + instance.result = ToolResult.load(data["result"], context.at("result")) if data is not None and "durationMs" in data: instance.duration_ms = data["durationMs"] if data is not None and "errorKind" in data: diff --git a/runtime/python/prompty/prompty/model/events/_ToolCallStartPayload.py b/runtime/python/prompty/prompty/model/events/_ToolCallStartPayload.py index 8870c6e68..6c873bbb3 100644 --- a/runtime/python/prompty/prompty/model/events/_ToolCallStartPayload.py +++ b/runtime/python/prompty/prompty/model/events/_ToolCallStartPayload.py @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolCallStartPayload """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ToolCallStartPayload: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_ToolExecutionCompletePayload.py b/runtime/python/prompty/prompty/model/events/_ToolExecutionCompletePayload.py index dbb9de241..0426629ba 100644 --- a/runtime/python/prompty/prompty/model/events/_ToolExecutionCompletePayload.py +++ b/runtime/python/prompty/prompty/model/events/_ToolExecutionCompletePayload.py @@ -64,8 +64,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolExecutionComplet """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ToolExecutionCompletePayload: {data}") @@ -92,7 +93,7 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolExecutionComplet if data is not None and "telemetry" in data: instance.telemetry = data["telemetry"] if data is not None and "redaction" in data: - instance.redaction = RedactionMetadata.load(data["redaction"], context) + instance.redaction = RedactionMetadata.load(data["redaction"], context.at("redaction")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/events/_ToolExecutionStartPayload.py b/runtime/python/prompty/prompty/model/events/_ToolExecutionStartPayload.py index 5421c4ac6..7faa16f2a 100644 --- a/runtime/python/prompty/prompty/model/events/_ToolExecutionStartPayload.py +++ b/runtime/python/prompty/prompty/model/events/_ToolExecutionStartPayload.py @@ -55,8 +55,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolExecutionStartPa """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ToolExecutionStartPayload: {data}") @@ -75,7 +76,7 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolExecutionStartPa if data is not None and "workingDirectory" in data: instance.working_directory = data["workingDirectory"] if data is not None and "redaction" in data: - instance.redaction = RedactionMetadata.load(data["redaction"], context) + instance.redaction = RedactionMetadata.load(data["redaction"], context.at("redaction")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/events/_ToolResultPayload.py b/runtime/python/prompty/prompty/model/events/_ToolResultPayload.py index d184dd716..c587676a4 100644 --- a/runtime/python/prompty/prompty/model/events/_ToolResultPayload.py +++ b/runtime/python/prompty/prompty/model/events/_ToolResultPayload.py @@ -40,11 +40,14 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolResultPayload": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ToolResultPayload: {data}") + if "result" not in data or data["result"] is None: + raise ValueError(f"{context.at('result').path}: missing required field") # create new instance instance = ToolResultPayload() @@ -52,7 +55,7 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolResultPayload": if data is not None and "name" in data: instance.name = data["name"] if data is not None and "result" in data: - instance.result = ToolResult.load(data["result"], context) + instance.result = ToolResult.load(data["result"], context.at("result")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/events/_TrajectoryEvent.py b/runtime/python/prompty/prompty/model/events/_TrajectoryEvent.py index aca0073fc..3db3b1a6b 100644 --- a/runtime/python/prompty/prompty/model/events/_TrajectoryEvent.py +++ b/runtime/python/prompty/prompty/model/events/_TrajectoryEvent.py @@ -61,8 +61,9 @@ def load(data: Any, context: LoadContext | None = None) -> "TrajectoryEvent": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TrajectoryEvent: {data}") @@ -87,7 +88,7 @@ def load(data: Any, context: LoadContext | None = None) -> "TrajectoryEvent": if data is not None and "createdAt" in data: instance.created_at = data["createdAt"] if data is not None and "redaction" in data: - instance.redaction = RedactionMetadata.load(data["redaction"], context) + instance.redaction = RedactionMetadata.load(data["redaction"], context.at("redaction")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/events/_TurnEndPayload.py b/runtime/python/prompty/prompty/model/events/_TurnEndPayload.py index 9c8b1dfd9..c5a05f2d1 100644 --- a/runtime/python/prompty/prompty/model/events/_TurnEndPayload.py +++ b/runtime/python/prompty/prompty/model/events/_TurnEndPayload.py @@ -47,8 +47,9 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnEndPayload": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TurnEndPayload: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_TurnEvent.py b/runtime/python/prompty/prompty/model/events/_TurnEvent.py index 4e447d8d7..51703b632 100644 --- a/runtime/python/prompty/prompty/model/events/_TurnEvent.py +++ b/runtime/python/prompty/prompty/model/events/_TurnEvent.py @@ -61,7 +61,7 @@ class TurnEvent: span_id : Optional[str] Trace span identifier associated with this event payload : dict[str, Any] - Event-specific payload. Use the typed payload model matching 'type'. + Event-specific payload. Values may be explicit null. Use the typed payload model matching 'type'. """ _shorthand_property: ClassVar[str | None] = None @@ -86,8 +86,9 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnEvent": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TurnEvent: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_TurnStartPayload.py b/runtime/python/prompty/prompty/model/events/_TurnStartPayload.py index bb45b0e13..51f34636e 100644 --- a/runtime/python/prompty/prompty/model/events/_TurnStartPayload.py +++ b/runtime/python/prompty/prompty/model/events/_TurnStartPayload.py @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnStartPayload": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TurnStartPayload: {data}") diff --git a/runtime/python/prompty/prompty/model/events/_TurnSummary.py b/runtime/python/prompty/prompty/model/events/_TurnSummary.py index 03bc0c37e..845b92842 100644 --- a/runtime/python/prompty/prompty/model/events/_TurnSummary.py +++ b/runtime/python/prompty/prompty/model/events/_TurnSummary.py @@ -58,8 +58,9 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnSummary": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TurnSummary: {data}") @@ -80,7 +81,7 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnSummary": if data is not None and "retries" in data: instance.retries = data["retries"] if data is not None and "usage" in data: - instance.usage = TokenUsage.load(data["usage"], context) + instance.usage = TokenUsage.load(data["usage"], context.at("usage")) if data is not None and "durationMs" in data: instance.duration_ms = data["durationMs"] if context is not None: diff --git a/runtime/python/prompty/prompty/model/events/_TurnTrace.py b/runtime/python/prompty/prompty/model/events/_TurnTrace.py index 99798bab3..106a95620 100644 --- a/runtime/python/prompty/prompty/model/events/_TurnTrace.py +++ b/runtime/python/prompty/prompty/model/events/_TurnTrace.py @@ -50,8 +50,9 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnTrace": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TurnTrace: {data}") @@ -66,34 +67,38 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnTrace": if data is not None and "promptyVersion" in data: instance.prompty_version = data["promptyVersion"] if data is not None and "events" in data: - instance.events = TurnTrace.load_events(data["events"], context) + instance.events = TurnTrace.load_events(data["events"], context.at("events")) if data is not None and "summary" in data: - instance.summary = TurnSummary.load(data["summary"], context) + instance.summary = TurnSummary.load(data["summary"], context.at("summary")) if context is not None: instance = context.process_output(instance) return instance @staticmethod def load_events(data: dict | list, context: LoadContext | None) -> list[TurnEvent]: + if context is None: + context = LoadContext(path="events") if isinstance(data, dict): # convert simple named events to list of TurnEvent result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(TurnEvent.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "id": v}) - data = result - return [TurnEvent.load(item, context) for item in data] + result.append(TurnEvent.load({"name": k, "id": v}, context.at(k))) + return result + return [TurnEvent.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_events(items: list[TurnEvent], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/memory/_MemoryEntry.py b/runtime/python/prompty/prompty/model/memory/_MemoryEntry.py index 7f82cc1da..f8785d0e8 100644 --- a/runtime/python/prompty/prompty/model/memory/_MemoryEntry.py +++ b/runtime/python/prompty/prompty/model/memory/_MemoryEntry.py @@ -43,7 +43,7 @@ class MemoryEntry: content: str = field(default="") category: MemoryCategory = field(default="core") created_at: str | None = None - tags: list[str] = field(default_factory=list) + tags: list[str] | None = None @staticmethod def load(data: Any, context: LoadContext | None = None) -> "MemoryEntry": @@ -56,8 +56,9 @@ def load(data: Any, context: LoadContext | None = None) -> "MemoryEntry": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for MemoryEntry: {data}") diff --git a/runtime/python/prompty/prompty/model/memory/_MemoryStore.py b/runtime/python/prompty/prompty/model/memory/_MemoryStore.py index 87494cf68..9423598d5 100644 --- a/runtime/python/prompty/prompty/model/memory/_MemoryStore.py +++ b/runtime/python/prompty/prompty/model/memory/_MemoryStore.py @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "MemoryStore": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for MemoryStore: {data}") @@ -52,32 +53,36 @@ def load(data: Any, context: LoadContext | None = None) -> "MemoryStore": instance = MemoryStore() if data is not None and "entries" in data: - instance.entries = MemoryStore.load_entries(data["entries"], context) + instance.entries = MemoryStore.load_entries(data["entries"], context.at("entries")) if context is not None: instance = context.process_output(instance) return instance @staticmethod def load_entries(data: dict | list, context: LoadContext | None) -> list[MemoryEntry]: + if context is None: + context = LoadContext(path="entries") if isinstance(data, dict): # convert simple named entries to list of MemoryEntry result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(MemoryEntry.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "content": v}) - data = result - return [MemoryEntry.load(item, context) for item in data] + result.append(MemoryEntry.load({"name": k, "content": v}, context.at(k))) + return result + return [MemoryEntry.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_entries(items: list[MemoryEntry], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/model/_AiResourceInfo.py b/runtime/python/prompty/prompty/model/model/_AiResourceInfo.py index 8226e7e95..e03ad06ec 100644 --- a/runtime/python/prompty/prompty/model/model/_AiResourceInfo.py +++ b/runtime/python/prompty/prompty/model/model/_AiResourceInfo.py @@ -51,8 +51,9 @@ def load(data: Any, context: LoadContext | None = None) -> "AiResourceInfo": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for AiResourceInfo: {data}") diff --git a/runtime/python/prompty/prompty/model/model/_InvocationUsage.py b/runtime/python/prompty/prompty/model/model/_InvocationUsage.py index 33e4a528b..7da9d1016 100644 --- a/runtime/python/prompty/prompty/model/model/_InvocationUsage.py +++ b/runtime/python/prompty/prompty/model/model/_InvocationUsage.py @@ -46,8 +46,9 @@ def load(data: Any, context: LoadContext | None = None) -> "InvocationUsage": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for InvocationUsage: {data}") diff --git a/runtime/python/prompty/prompty/model/model/_Model.py b/runtime/python/prompty/prompty/model/model/_Model.py index aa0341c59..2d8ca931e 100644 --- a/runtime/python/prompty/prompty/model/model/_Model.py +++ b/runtime/python/prompty/prompty/model/model/_Model.py @@ -54,8 +54,9 @@ def load(data: Any, context: LoadContext | None = None) -> "Model": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) # handle alternate representations if isinstance(data, str): @@ -78,9 +79,9 @@ def load(data: Any, context: LoadContext | None = None) -> "Model": if data is not None and "apiType" in data: instance.api_type = data["apiType"] if data is not None and "connection" in data: - instance.connection = Connection.load(data["connection"], context) + instance.connection = Connection.load(data["connection"], context.at("connection")) if data is not None and "options" in data: - instance.options = ModelOptions.load(data["options"], context) + instance.options = ModelOptions.load(data["options"], context.at("options")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/model/_ModelInfo.py b/runtime/python/prompty/prompty/model/model/_ModelInfo.py index 263038fdb..b2daf4b0a 100644 --- a/runtime/python/prompty/prompty/model/model/_ModelInfo.py +++ b/runtime/python/prompty/prompty/model/model/_ModelInfo.py @@ -35,7 +35,7 @@ class ModelInfo: output_modalities : Optional[list[str]] Output modalities the model can produce (e.g., 'text', 'audio') additional_properties : Optional[dict[str, Any]] - Additional provider-specific properties + Additional provider-specific properties. Values may be explicit null. """ _shorthand_property: ClassVar[str | None] = None @@ -44,8 +44,8 @@ class ModelInfo: display_name: str | None = None owned_by: str | None = None context_window: int | None = None - input_modalities: list[str] = field(default_factory=list) - output_modalities: list[str] = field(default_factory=list) + input_modalities: list[str] | None = None + output_modalities: list[str] | None = None additional_properties: dict[str, Any] | None = None @staticmethod @@ -59,8 +59,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ModelInfo": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ModelInfo: {data}") diff --git a/runtime/python/prompty/prompty/model/model/_ModelOptions.py b/runtime/python/prompty/prompty/model/model/_ModelOptions.py index a73310d76..ad9fbe65a 100644 --- a/runtime/python/prompty/prompty/model/model/_ModelOptions.py +++ b/runtime/python/prompty/prompty/model/model/_ModelOptions.py @@ -5,7 +5,7 @@ # ANY EDITS WILL BE LOST ########################################## -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, ClassVar from .._context import LoadContext, SaveContext @@ -48,7 +48,7 @@ class ModelOptions: temperature: float | None = None top_k: int | None = None top_p: float | None = None - stop_sequences: list[str] = field(default_factory=list) + stop_sequences: list[str] | None = None allow_multiple_tool_calls: bool | None = None additional_properties: dict[str, Any] | None = None @@ -63,8 +63,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ModelOptions": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ModelOptions: {data}") diff --git a/runtime/python/prompty/prompty/model/model/_ProjectInfo.py b/runtime/python/prompty/prompty/model/model/_ProjectInfo.py index 0506372a0..f2a806d3d 100644 --- a/runtime/python/prompty/prompty/model/model/_ProjectInfo.py +++ b/runtime/python/prompty/prompty/model/model/_ProjectInfo.py @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ProjectInfo": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ProjectInfo: {data}") diff --git a/runtime/python/prompty/prompty/model/model/_SubscriptionInfo.py b/runtime/python/prompty/prompty/model/model/_SubscriptionInfo.py index ea5e301e7..6609946a4 100644 --- a/runtime/python/prompty/prompty/model/model/_SubscriptionInfo.py +++ b/runtime/python/prompty/prompty/model/model/_SubscriptionInfo.py @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "SubscriptionInfo": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for SubscriptionInfo: {data}") diff --git a/runtime/python/prompty/prompty/model/model/_TokenUsage.py b/runtime/python/prompty/prompty/model/model/_TokenUsage.py index e4ecd1ae8..48d0b07d2 100644 --- a/runtime/python/prompty/prompty/model/model/_TokenUsage.py +++ b/runtime/python/prompty/prompty/model/model/_TokenUsage.py @@ -44,8 +44,9 @@ def load(data: Any, context: LoadContext | None = None) -> "TokenUsage": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TokenUsage: {data}") diff --git a/runtime/python/prompty/prompty/model/pipeline/_CompactionConfig.py b/runtime/python/prompty/prompty/model/pipeline/_CompactionConfig.py index 112bf9d0a..5a36c9e68 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_CompactionConfig.py +++ b/runtime/python/prompty/prompty/model/pipeline/_CompactionConfig.py @@ -44,8 +44,9 @@ def load(data: Any, context: LoadContext | None = None) -> "CompactionConfig": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for CompactionConfig: {data}") diff --git a/runtime/python/prompty/prompty/model/pipeline/_ContextCandidate.py b/runtime/python/prompty/prompty/model/pipeline/_ContextCandidate.py index 085811394..225a48958 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_ContextCandidate.py +++ b/runtime/python/prompty/prompty/model/pipeline/_ContextCandidate.py @@ -46,8 +46,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ContextCandidate": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ContextCandidate: {data}") @@ -60,7 +61,7 @@ def load(data: Any, context: LoadContext | None = None) -> "ContextCandidate": if data is not None and "source" in data: instance.source = data["source"] if data is not None and "messages" in data: - instance.messages = ContextCandidate.load_messages(data["messages"], context) + instance.messages = ContextCandidate.load_messages(data["messages"], context.at("messages")) if data is not None and "metadata" in data: instance.metadata = data["metadata"] if context is not None: @@ -69,25 +70,29 @@ def load(data: Any, context: LoadContext | None = None) -> "ContextCandidate": @staticmethod def load_messages(data: dict | list, context: LoadContext | None) -> list[Message]: + if context is None: + context = LoadContext(path="messages") if isinstance(data, dict): # convert simple named messages to list of Message result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Message.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "role": v}) - data = result - return [Message.load(item, context) for item in data] + result.append(Message.load({"name": k, "role": v}, context.at(k))) + return result + return [Message.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_messages(items: list[Message], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/pipeline/_ContextRequest.py b/runtime/python/prompty/prompty/model/pipeline/_ContextRequest.py index e2dd20ced..d226324b5 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_ContextRequest.py +++ b/runtime/python/prompty/prompty/model/pipeline/_ContextRequest.py @@ -59,11 +59,14 @@ def load(data: Any, context: LoadContext | None = None) -> "ContextRequest": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ContextRequest: {data}") + if "contextState" not in data or data["contextState"] is None: + raise ValueError(f"{context.at('contextState').path}: missing required field") # create new instance instance = ContextRequest() @@ -77,11 +80,11 @@ def load(data: Any, context: LoadContext | None = None) -> "ContextRequest": if data is not None and "iteration" in data: instance.iteration = data["iteration"] if data is not None and "messages" in data: - instance.messages = ContextRequest.load_messages(data["messages"], context) + instance.messages = ContextRequest.load_messages(data["messages"], context.at("messages")) if data is not None and "stablePrefixMessages" in data: instance.stable_prefix_messages = data["stablePrefixMessages"] if data is not None and "contextState" in data: - instance.context_state = InvocationContextState.load(data["contextState"], context) + instance.context_state = InvocationContextState.load(data["contextState"], context.at("contextState")) if data is not None and "inputs" in data: instance.inputs = data["inputs"] if context is not None: @@ -90,25 +93,29 @@ def load(data: Any, context: LoadContext | None = None) -> "ContextRequest": @staticmethod def load_messages(data: dict | list, context: LoadContext | None) -> list[Message]: + if context is None: + context = LoadContext(path="messages") if isinstance(data, dict): # convert simple named messages to list of Message result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Message.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "role": v}) - data = result - return [Message.load(item, context) for item in data] + result.append(Message.load({"name": k, "role": v}, context.at(k))) + return result + return [Message.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_messages(items: list[Message], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/pipeline/_DelegatedStateReference.py b/runtime/python/prompty/prompty/model/pipeline/_DelegatedStateReference.py index 75c9719d7..0c8de4a38 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_DelegatedStateReference.py +++ b/runtime/python/prompty/prompty/model/pipeline/_DelegatedStateReference.py @@ -45,8 +45,9 @@ def load(data: Any, context: LoadContext | None = None) -> "DelegatedStateRefere """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for DelegatedStateReference: {data}") diff --git a/runtime/python/prompty/prompty/model/pipeline/_EngineCheckpoint.py b/runtime/python/prompty/prompty/model/pipeline/_EngineCheckpoint.py index e0a4a8c6a..90689588c 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_EngineCheckpoint.py +++ b/runtime/python/prompty/prompty/model/pipeline/_EngineCheckpoint.py @@ -91,8 +91,8 @@ class EngineCheckpoint: stable_prefix_messages: int = field(default=0) inputs: Any | None = None active_invocation_id: str | None = None - pending_tool_requests: list[ModelToolRequest] = field(default_factory=list) - completed_tool_results: list[ModelToolResult] = field(default_factory=list) + pending_tool_requests: list[ModelToolRequest] | None = field(default_factory=list) + completed_tool_results: list[ModelToolResult] | None = field(default_factory=list) completed_model_iterations: int = field(default=0) reconciliation_required: bool = field(default=False) model_reconciliation: ModelReconciliationState | None = None @@ -115,11 +115,14 @@ def load(data: Any, context: LoadContext | None = None) -> "EngineCheckpoint": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for EngineCheckpoint: {data}") + if "contextState" not in data or data["contextState"] is None: + raise ValueError(f"{context.at('contextState').path}: missing required field") # create new instance instance = EngineCheckpoint() @@ -141,7 +144,7 @@ def load(data: Any, context: LoadContext | None = None) -> "EngineCheckpoint": if data is not None and "lastSequence" in data: instance.last_sequence = data["lastSequence"] if data is not None and "messages" in data: - instance.messages = EngineCheckpoint.load_messages(data["messages"], context) + instance.messages = EngineCheckpoint.load_messages(data["messages"], context.at("messages")) if data is not None and "stablePrefixMessages" in data: instance.stable_prefix_messages = data["stablePrefixMessages"] if data is not None and "inputs" in data: @@ -150,30 +153,34 @@ def load(data: Any, context: LoadContext | None = None) -> "EngineCheckpoint": instance.active_invocation_id = data["activeInvocationId"] if data is not None and "pendingToolRequests" in data: instance.pending_tool_requests = EngineCheckpoint.load_pending_tool_requests( - data["pendingToolRequests"], context + data["pendingToolRequests"], context.at("pendingToolRequests") ) if data is not None and "completedToolResults" in data: instance.completed_tool_results = EngineCheckpoint.load_completed_tool_results( - data["completedToolResults"], context + data["completedToolResults"], context.at("completedToolResults") ) if data is not None and "completedModelIterations" in data: instance.completed_model_iterations = data["completedModelIterations"] if data is not None and "reconciliationRequired" in data: instance.reconciliation_required = data["reconciliationRequired"] if data is not None and "modelReconciliation" in data: - instance.model_reconciliation = ModelReconciliationState.load(data["modelReconciliation"], context) + instance.model_reconciliation = ModelReconciliationState.load( + data["modelReconciliation"], context.at("modelReconciliation") + ) if data is not None and "pendingOutput" in data: instance.pending_output = data["pendingOutput"] if data is not None and "finalOutputReady" in data: instance.final_output_ready = data["finalOutputReady"] if data is not None and "pendingModelResponse" in data: - instance.pending_model_response = ModelInvocationResponse.load(data["pendingModelResponse"], context) + instance.pending_model_response = ModelInvocationResponse.load( + data["pendingModelResponse"], context.at("pendingModelResponse") + ) if data is not None and "resumeSameIteration" in data: instance.resume_same_iteration = data["resumeSameIteration"] if data is not None and "policyAppliedForIteration" in data: instance.policy_applied_for_iteration = data["policyAppliedForIteration"] if data is not None and "contextState" in data: - instance.context_state = InvocationContextState.load(data["contextState"], context) + instance.context_state = InvocationContextState.load(data["contextState"], context.at("contextState")) if data is not None and "metadata" in data: instance.metadata = data["metadata"] if context is not None: @@ -182,41 +189,49 @@ def load(data: Any, context: LoadContext | None = None) -> "EngineCheckpoint": @staticmethod def load_messages(data: dict | list, context: LoadContext | None) -> list[Message]: + if context is None: + context = LoadContext(path="messages") if isinstance(data, dict): # convert simple named messages to list of Message result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Message.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "role": v}) - data = result - return [Message.load(item, context) for item in data] + result.append(Message.load({"name": k, "role": v}, context.at(k))) + return result + return [Message.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_messages(items: list[Message], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] @staticmethod def load_pending_tool_requests(data: dict | list, context: LoadContext | None) -> list[ModelToolRequest]: + if context is None: + context = LoadContext(path="pendingToolRequests") if isinstance(data, dict): # convert simple named pendingToolRequests to list of ModelToolRequest result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(ModelToolRequest.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "id": v}) - data = result - return [ModelToolRequest.load(item, context) for item in data] + result.append(ModelToolRequest.load({"name": k, "id": v}, context.at(k))) + return result + return [ModelToolRequest.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_pending_tool_requests( @@ -225,43 +240,27 @@ def save_pending_tool_requests( if context is None: context = SaveContext() - if context.collection_format == "array": - return [item.save(context) for item in items] - - # Object format: use name as key - result: dict[str, Any] = {} - for item in items: - item_data = item.save(context) - name = item_data.pop("name", None) - if name: - # Check if we can use shorthand (only primary property set) - if context.use_shorthand and hasattr(item, "_shorthand_property"): - shorthand_prop = item._shorthand_property - if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: - result[name] = item_data[shorthand_prop] - continue - result[name] = item_data - else: - # No name, fall back to array format for this item - if "_unnamed" not in result: - result["_unnamed"] = [] - result["_unnamed"].append(item_data) - return result + # The schema declares an ordered collection, so preserve array format + return [item.save(context) for item in items] @staticmethod def load_completed_tool_results(data: dict | list, context: LoadContext | None) -> list[ModelToolResult]: + if context is None: + context = LoadContext(path="completedToolResults") if isinstance(data, dict): # convert simple named completedToolResults to list of ModelToolResult result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(ModelToolResult.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "requestId": v}) - data = result - return [ModelToolResult.load(item, context) for item in data] + result.append(ModelToolResult.load({"name": k, "requestId": v}, context.at(k))) + return result + return [ModelToolResult.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_completed_tool_results( @@ -270,28 +269,8 @@ def save_completed_tool_results( if context is None: context = SaveContext() - if context.collection_format == "array": - return [item.save(context) for item in items] - - # Object format: use name as key - result: dict[str, Any] = {} - for item in items: - item_data = item.save(context) - name = item_data.pop("name", None) - if name: - # Check if we can use shorthand (only primary property set) - if context.use_shorthand and hasattr(item, "_shorthand_property"): - shorthand_prop = item._shorthand_property - if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: - result[name] = item_data[shorthand_prop] - continue - result[name] = item_data - else: - # No name, fall back to array format for this item - if "_unnamed" not in result: - result["_unnamed"] = [] - result["_unnamed"].append(item_data) - return result + # The schema declares an ordered collection, so preserve array format + return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: """Save the EngineCheckpoint instance to a dictionary. diff --git a/runtime/python/prompty/prompty/model/pipeline/_EngineDurabilityPort.py b/runtime/python/prompty/prompty/model/pipeline/_EngineDurabilityPort.py new file mode 100644 index 000000000..69e411c0a --- /dev/null +++ b/runtime/python/prompty/prompty/model/pipeline/_EngineDurabilityPort.py @@ -0,0 +1,32 @@ +# +########################################## +# WARNING: This is an auto-generated file. +# DO NOT EDIT THIS FILE DIRECTLY +# ANY EDITS WILL BE LOST +########################################## + +from typing import Protocol, runtime_checkable + +from ._EngineCheckpoint import EngineCheckpoint +from ._EngineEvent import EngineEvent + + +@runtime_checkable +class EngineDurabilityPort(Protocol): + """Persists semantic engine events and checkpoints without runtime cancellation.""" + + def append(self, event: EngineEvent) -> None: + """Append one semantic engine event durably""" + raise NotImplementedError + + async def append_async(self, event: EngineEvent) -> None: + """Append one semantic engine event durably (async variant)""" + raise NotImplementedError + + def append_with_checkpoint(self, events: list[EngineEvent], checkpoint: EngineCheckpoint) -> None: + """Atomically append semantic engine events and persist the checkpoint that reflects them""" + raise NotImplementedError + + async def append_with_checkpoint_async(self, events: list[EngineEvent], checkpoint: EngineCheckpoint) -> None: + """Atomically append semantic engine events and persist the checkpoint that reflects them (async variant)""" + raise NotImplementedError diff --git a/runtime/python/prompty/prompty/model/pipeline/_EngineEvent.py b/runtime/python/prompty/prompty/model/pipeline/_EngineEvent.py index 72d16b24d..a56bcd78c 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_EngineEvent.py +++ b/runtime/python/prompty/prompty/model/pipeline/_EngineEvent.py @@ -99,8 +99,9 @@ def load(data: Any, context: LoadContext | None = None) -> "EngineEvent": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for EngineEvent: {data}") diff --git a/runtime/python/prompty/prompty/model/pipeline/_EnginePermissionDecision.py b/runtime/python/prompty/prompty/model/pipeline/_EnginePermissionDecision.py index f25155931..2799a83c9 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_EnginePermissionDecision.py +++ b/runtime/python/prompty/prompty/model/pipeline/_EnginePermissionDecision.py @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "EnginePermissionDeci """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for EnginePermissionDecision: {data}") diff --git a/runtime/python/prompty/prompty/model/pipeline/_EnginePermissionPort.py b/runtime/python/prompty/prompty/model/pipeline/_EnginePermissionPort.py new file mode 100644 index 000000000..cae41a9e0 --- /dev/null +++ b/runtime/python/prompty/prompty/model/pipeline/_EnginePermissionPort.py @@ -0,0 +1,29 @@ +# +########################################## +# WARNING: This is an auto-generated file. +# DO NOT EDIT THIS FILE DIRECTLY +# ANY EDITS WILL BE LOST +########################################## + +from typing import Protocol, runtime_checkable + +from ...core.cancellation import CancellationToken +from ._EnginePermissionDecision import EnginePermissionDecision +from ._ModelToolRequest import ModelToolRequest + + +@runtime_checkable +class EnginePermissionPort(Protocol): + """Authorizes model-requested tools at a runtime cancellation boundary.""" + + def authorize( + self, request: ModelToolRequest, cancellation: CancellationToken | None = None + ) -> EnginePermissionDecision: + """Authorize one model-requested tool before execution""" + raise NotImplementedError + + async def authorize_async( + self, request: ModelToolRequest, cancellation: CancellationToken | None = None + ) -> EnginePermissionDecision: + """Authorize one model-requested tool before execution (async variant)""" + raise NotImplementedError diff --git a/runtime/python/prompty/prompty/model/pipeline/_EnginePostCommitPort.py b/runtime/python/prompty/prompty/model/pipeline/_EnginePostCommitPort.py new file mode 100644 index 000000000..170a2a901 --- /dev/null +++ b/runtime/python/prompty/prompty/model/pipeline/_EnginePostCommitPort.py @@ -0,0 +1,26 @@ +# +########################################## +# WARNING: This is an auto-generated file. +# DO NOT EDIT THIS FILE DIRECTLY +# ANY EDITS WILL BE LOST +########################################## + +from typing import Protocol, runtime_checkable + +from ...core.cancellation import CancellationToken +from ._TurnCommit import TurnCommit + + +@runtime_checkable +class EnginePostCommitPort(Protocol): + """Runs non-fatal host effects after a turn is durably committed.""" + + def after_commit(self, effect_id: str, commit: TurnCommit, cancellation: CancellationToken | None = None) -> None: + """Run one idempotent host effect after the turn is durably committed""" + raise NotImplementedError + + async def after_commit_async( + self, effect_id: str, commit: TurnCommit, cancellation: CancellationToken | None = None + ) -> None: + """Run one idempotent host effect after the turn is durably committed (async variant)""" + raise NotImplementedError diff --git a/runtime/python/prompty/prompty/model/pipeline/_EngineToolPort.py b/runtime/python/prompty/prompty/model/pipeline/_EngineToolPort.py new file mode 100644 index 000000000..b8b5ce914 --- /dev/null +++ b/runtime/python/prompty/prompty/model/pipeline/_EngineToolPort.py @@ -0,0 +1,27 @@ +# +########################################## +# WARNING: This is an auto-generated file. +# DO NOT EDIT THIS FILE DIRECTLY +# ANY EDITS WILL BE LOST +########################################## + +from typing import Protocol, runtime_checkable + +from ...core.cancellation import CancellationToken +from ._ModelToolRequest import ModelToolRequest +from ._ModelToolResult import ModelToolResult + + +@runtime_checkable +class EngineToolPort(Protocol): + """Executes authorized model-requested tools at a runtime cancellation boundary.""" + + def execute(self, request: ModelToolRequest, cancellation: CancellationToken | None = None) -> ModelToolResult: + """Execute one authorized model-requested tool""" + raise NotImplementedError + + async def execute_async( + self, request: ModelToolRequest, cancellation: CancellationToken | None = None + ) -> ModelToolResult: + """Execute one authorized model-requested tool (async variant)""" + raise NotImplementedError diff --git a/runtime/python/prompty/prompty/model/pipeline/_Executor.py b/runtime/python/prompty/prompty/model/pipeline/_Executor.py index ccafa4338..cddd9691f 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_Executor.py +++ b/runtime/python/prompty/prompty/model/pipeline/_Executor.py @@ -7,6 +7,7 @@ from typing import Any, Protocol, runtime_checkable +from ...core.cancellation import CancellationToken from ..agent._Prompty import Prompty from ..conversation._Message import Message from ..conversation._ToolCall import ToolCall @@ -16,19 +17,25 @@ class Executor(Protocol): """Calls an LLM provider with messages and returns the raw provider response.""" - def execute(self, agent: Prompty, messages: list[Message]) -> Any: + def execute(self, agent: Prompty, messages: list[Message], cancellation: CancellationToken | None = None) -> Any: """Call an LLM provider with messages and return the raw response""" raise NotImplementedError - async def execute_async(self, agent: Prompty, messages: list[Message]) -> Any: + async def execute_async( + self, agent: Prompty, messages: list[Message], cancellation: CancellationToken | None = None + ) -> Any: """Call an LLM provider with messages and return the raw response (async variant)""" raise NotImplementedError - def execute_stream(self, agent: Prompty, messages: list[Message]) -> Any: + def execute_stream( + self, agent: Prompty, messages: list[Message], cancellation: CancellationToken | None = None + ) -> Any: """Call an LLM provider and return a streaming response. Returns a language-specific async iterable/stream of raw chunks. Not all providers support streaming; the default implementation should signal lack of support.""" return None - async def execute_stream_async(self, agent: Prompty, messages: list[Message]) -> Any: + async def execute_stream_async( + self, agent: Prompty, messages: list[Message], cancellation: CancellationToken | None = None + ) -> Any: """Call an LLM provider and return a streaming response. Returns a language-specific async iterable/stream of raw chunks. Not all providers support streaming; the default implementation should signal lack of support. (async variant)""" return None diff --git a/runtime/python/prompty/prompty/model/pipeline/_FinalOutputPolicyRequest.py b/runtime/python/prompty/prompty/model/pipeline/_FinalOutputPolicyRequest.py index 33251a4b8..ae5e0a7df 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_FinalOutputPolicyRequest.py +++ b/runtime/python/prompty/prompty/model/pipeline/_FinalOutputPolicyRequest.py @@ -52,8 +52,9 @@ def load(data: Any, context: LoadContext | None = None) -> "FinalOutputPolicyReq """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for FinalOutputPolicyRequest: {data}") @@ -68,7 +69,7 @@ def load(data: Any, context: LoadContext | None = None) -> "FinalOutputPolicyReq if data is not None and "iteration" in data: instance.iteration = data["iteration"] if data is not None and "messages" in data: - instance.messages = FinalOutputPolicyRequest.load_messages(data["messages"], context) + instance.messages = FinalOutputPolicyRequest.load_messages(data["messages"], context.at("messages")) if data is not None and "output" in data: instance.output = data["output"] if data is not None and "inputs" in data: @@ -79,25 +80,29 @@ def load(data: Any, context: LoadContext | None = None) -> "FinalOutputPolicyReq @staticmethod def load_messages(data: dict | list, context: LoadContext | None) -> list[Message]: + if context is None: + context = LoadContext(path="messages") if isinstance(data, dict): # convert simple named messages to list of Message result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Message.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "role": v}) - data = result - return [Message.load(item, context) for item in data] + result.append(Message.load({"name": k, "role": v}, context.at(k))) + return result + return [Message.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_messages(items: list[Message], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/pipeline/_FinalOutputPolicyResult.py b/runtime/python/prompty/prompty/model/pipeline/_FinalOutputPolicyResult.py index 81b5fa185..3e47efc4a 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_FinalOutputPolicyResult.py +++ b/runtime/python/prompty/prompty/model/pipeline/_FinalOutputPolicyResult.py @@ -39,8 +39,9 @@ def load(data: Any, context: LoadContext | None = None) -> "FinalOutputPolicyRes """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for FinalOutputPolicyResult: {data}") diff --git a/runtime/python/prompty/prompty/model/pipeline/_HostPolicyRequest.py b/runtime/python/prompty/prompty/model/pipeline/_HostPolicyRequest.py index 9bfbbb134..fff884b3d 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_HostPolicyRequest.py +++ b/runtime/python/prompty/prompty/model/pipeline/_HostPolicyRequest.py @@ -52,8 +52,9 @@ def load(data: Any, context: LoadContext | None = None) -> "HostPolicyRequest": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for HostPolicyRequest: {data}") @@ -68,7 +69,7 @@ def load(data: Any, context: LoadContext | None = None) -> "HostPolicyRequest": if data is not None and "iteration" in data: instance.iteration = data["iteration"] if data is not None and "messages" in data: - instance.messages = HostPolicyRequest.load_messages(data["messages"], context) + instance.messages = HostPolicyRequest.load_messages(data["messages"], context.at("messages")) if data is not None and "stablePrefixMessages" in data: instance.stable_prefix_messages = data["stablePrefixMessages"] if data is not None and "inputs" in data: @@ -79,25 +80,29 @@ def load(data: Any, context: LoadContext | None = None) -> "HostPolicyRequest": @staticmethod def load_messages(data: dict | list, context: LoadContext | None) -> list[Message]: + if context is None: + context = LoadContext(path="messages") if isinstance(data, dict): # convert simple named messages to list of Message result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Message.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "role": v}) - data = result - return [Message.load(item, context) for item in data] + result.append(Message.load({"name": k, "role": v}, context.at(k))) + return result + return [Message.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_messages(items: list[Message], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/pipeline/_HostPolicyResult.py b/runtime/python/prompty/prompty/model/pipeline/_HostPolicyResult.py index 1d47e9d5b..17dd8d544 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_HostPolicyResult.py +++ b/runtime/python/prompty/prompty/model/pipeline/_HostPolicyResult.py @@ -43,8 +43,9 @@ def load(data: Any, context: LoadContext | None = None) -> "HostPolicyResult": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for HostPolicyResult: {data}") @@ -53,7 +54,7 @@ def load(data: Any, context: LoadContext | None = None) -> "HostPolicyResult": instance = HostPolicyResult() if data is not None and "messages" in data: - instance.messages = HostPolicyResult.load_messages(data["messages"], context) + instance.messages = HostPolicyResult.load_messages(data["messages"], context.at("messages")) if data is not None and "stablePrefixMessages" in data: instance.stable_prefix_messages = data["stablePrefixMessages"] if data is not None and "metadata" in data: @@ -64,25 +65,29 @@ def load(data: Any, context: LoadContext | None = None) -> "HostPolicyResult": @staticmethod def load_messages(data: dict | list, context: LoadContext | None) -> list[Message]: + if context is None: + context = LoadContext(path="messages") if isinstance(data, dict): # convert simple named messages to list of Message result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Message.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "role": v}) - data = result - return [Message.load(item, context) for item in data] + result.append(Message.load({"name": k, "role": v}, context.at(k))) + return result + return [Message.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_messages(items: list[Message], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/pipeline/_InvocationContextDecision.py b/runtime/python/prompty/prompty/model/pipeline/_InvocationContextDecision.py index 1f0af0405..2cd1297d4 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_InvocationContextDecision.py +++ b/runtime/python/prompty/prompty/model/pipeline/_InvocationContextDecision.py @@ -53,8 +53,9 @@ def load(data: Any, context: LoadContext | None = None) -> "InvocationContextDec """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for InvocationContextDecision: {data}") diff --git a/runtime/python/prompty/prompty/model/pipeline/_InvocationContextState.py b/runtime/python/prompty/prompty/model/pipeline/_InvocationContextState.py index a01ca2404..7d19c733b 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_InvocationContextState.py +++ b/runtime/python/prompty/prompty/model/pipeline/_InvocationContextState.py @@ -29,7 +29,7 @@ class InvocationContextState: _shorthand_property: ClassVar[str | None] = None portability: InvocationContextPortability = field(default="portable") - delegated_state: list[DelegatedStateReference] = field(default_factory=list) + delegated_state: list[DelegatedStateReference] | None = field(default_factory=list) @staticmethod def load(data: Any, context: LoadContext | None = None) -> "InvocationContextState": @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "InvocationContextSta """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for InvocationContextState: {data}") @@ -54,25 +55,31 @@ def load(data: Any, context: LoadContext | None = None) -> "InvocationContextSta if data is not None and "portability" in data: instance.portability = data["portability"] if data is not None and "delegatedState" in data: - instance.delegated_state = InvocationContextState.load_delegated_state(data["delegatedState"], context) + instance.delegated_state = InvocationContextState.load_delegated_state( + data["delegatedState"], context.at("delegatedState") + ) if context is not None: instance = context.process_output(instance) return instance @staticmethod def load_delegated_state(data: dict | list, context: LoadContext | None) -> list[DelegatedStateReference]: + if context is None: + context = LoadContext(path="delegatedState") if isinstance(data, dict): # convert simple named delegatedState to list of DelegatedStateReference result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(DelegatedStateReference.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "provider": v}) - data = result - return [DelegatedStateReference.load(item, context) for item in data] + result.append(DelegatedStateReference.load({"name": k, "provider": v}, context.at(k))) + return result + return [DelegatedStateReference.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_delegated_state( @@ -81,7 +88,7 @@ def save_delegated_state( if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/pipeline/_ModelInvocationContextSnapshot.py b/runtime/python/prompty/prompty/model/pipeline/_ModelInvocationContextSnapshot.py index 9693a2f2e..f72760f19 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_ModelInvocationContextSnapshot.py +++ b/runtime/python/prompty/prompty/model/pipeline/_ModelInvocationContextSnapshot.py @@ -52,7 +52,7 @@ class ModelInvocationContextSnapshot: invocation_id: str = field(default="") iteration: int = field(default=0) messages: list[Message] = field(default_factory=list) - decisions: list[InvocationContextDecision] = field(default_factory=list) + decisions: list[InvocationContextDecision] | None = field(default_factory=list) stable_prefix_messages: int = field(default=0) context_state: InvocationContextState = field(default_factory=InvocationContextState) metadata: dict[str, Any] | None = None @@ -68,11 +68,14 @@ def load(data: Any, context: LoadContext | None = None) -> "ModelInvocationConte """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ModelInvocationContextSnapshot: {data}") + if "contextState" not in data or data["contextState"] is None: + raise ValueError(f"{context.at('contextState').path}: missing required field") # create new instance instance = ModelInvocationContextSnapshot() @@ -88,13 +91,15 @@ def load(data: Any, context: LoadContext | None = None) -> "ModelInvocationConte if data is not None and "iteration" in data: instance.iteration = data["iteration"] if data is not None and "messages" in data: - instance.messages = ModelInvocationContextSnapshot.load_messages(data["messages"], context) + instance.messages = ModelInvocationContextSnapshot.load_messages(data["messages"], context.at("messages")) if data is not None and "decisions" in data: - instance.decisions = ModelInvocationContextSnapshot.load_decisions(data["decisions"], context) + instance.decisions = ModelInvocationContextSnapshot.load_decisions( + data["decisions"], context.at("decisions") + ) if data is not None and "stablePrefixMessages" in data: instance.stable_prefix_messages = data["stablePrefixMessages"] if data is not None and "contextState" in data: - instance.context_state = InvocationContextState.load(data["contextState"], context) + instance.context_state = InvocationContextState.load(data["contextState"], context.at("contextState")) if data is not None and "metadata" in data: instance.metadata = data["metadata"] if context is not None: @@ -103,41 +108,49 @@ def load(data: Any, context: LoadContext | None = None) -> "ModelInvocationConte @staticmethod def load_messages(data: dict | list, context: LoadContext | None) -> list[Message]: + if context is None: + context = LoadContext(path="messages") if isinstance(data, dict): # convert simple named messages to list of Message result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Message.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "role": v}) - data = result - return [Message.load(item, context) for item in data] + result.append(Message.load({"name": k, "role": v}, context.at(k))) + return result + return [Message.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_messages(items: list[Message], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] @staticmethod def load_decisions(data: dict | list, context: LoadContext | None) -> list[InvocationContextDecision]: + if context is None: + context = LoadContext(path="decisions") if isinstance(data, dict): # convert simple named decisions to list of InvocationContextDecision result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(InvocationContextDecision.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "candidateId": v}) - data = result - return [InvocationContextDecision.load(item, context) for item in data] + result.append(InvocationContextDecision.load({"name": k, "candidateId": v}, context.at(k))) + return result + return [InvocationContextDecision.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_decisions( @@ -146,7 +159,7 @@ def save_decisions( if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/pipeline/_ModelInvocationRequest.py b/runtime/python/prompty/prompty/model/pipeline/_ModelInvocationRequest.py index b56b29722..88cf3b01f 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_ModelInvocationRequest.py +++ b/runtime/python/prompty/prompty/model/pipeline/_ModelInvocationRequest.py @@ -37,17 +37,20 @@ def load(data: Any, context: LoadContext | None = None) -> "ModelInvocationReque """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ModelInvocationRequest: {data}") + if "context" not in data or data["context"] is None: + raise ValueError(f"{context.at('context').path}: missing required field") # create new instance instance = ModelInvocationRequest() if data is not None and "context" in data: - instance.context = ModelInvocationContextSnapshot.load(data["context"], context) + instance.context = ModelInvocationContextSnapshot.load(data["context"], context.at("context")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/pipeline/_ModelInvocationResponse.py b/runtime/python/prompty/prompty/model/pipeline/_ModelInvocationResponse.py index 3a77b6957..639c48594 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_ModelInvocationResponse.py +++ b/runtime/python/prompty/prompty/model/pipeline/_ModelInvocationResponse.py @@ -42,8 +42,8 @@ class ModelInvocationResponse: output: Any | None = None usage: InvocationUsage | None = None - assistant_messages: list[Message] = field(default_factory=list) - tool_requests: list[ModelToolRequest] = field(default_factory=list) + assistant_messages: list[Message] | None = field(default_factory=list) + tool_requests: list[ModelToolRequest] | None = field(default_factory=list) next_context_state: InvocationContextState | None = None metadata: dict[str, Any] | None = None @@ -58,8 +58,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ModelInvocationRespo """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ModelInvocationResponse: {data}") @@ -70,15 +71,19 @@ def load(data: Any, context: LoadContext | None = None) -> "ModelInvocationRespo if data is not None and "output" in data: instance.output = data["output"] if data is not None and "usage" in data: - instance.usage = InvocationUsage.load(data["usage"], context) + instance.usage = InvocationUsage.load(data["usage"], context.at("usage")) if data is not None and "assistantMessages" in data: instance.assistant_messages = ModelInvocationResponse.load_assistant_messages( - data["assistantMessages"], context + data["assistantMessages"], context.at("assistantMessages") ) if data is not None and "toolRequests" in data: - instance.tool_requests = ModelInvocationResponse.load_tool_requests(data["toolRequests"], context) + instance.tool_requests = ModelInvocationResponse.load_tool_requests( + data["toolRequests"], context.at("toolRequests") + ) if data is not None and "nextContextState" in data: - instance.next_context_state = InvocationContextState.load(data["nextContextState"], context) + instance.next_context_state = InvocationContextState.load( + data["nextContextState"], context.at("nextContextState") + ) if data is not None and "metadata" in data: instance.metadata = data["metadata"] if context is not None: @@ -87,18 +92,22 @@ def load(data: Any, context: LoadContext | None = None) -> "ModelInvocationRespo @staticmethod def load_assistant_messages(data: dict | list, context: LoadContext | None) -> list[Message]: + if context is None: + context = LoadContext(path="assistantMessages") if isinstance(data, dict): # convert simple named assistantMessages to list of Message result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Message.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "role": v}) - data = result - return [Message.load(item, context) for item in data] + result.append(Message.load({"name": k, "role": v}, context.at(k))) + return result + return [Message.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_assistant_messages( @@ -107,23 +116,27 @@ def save_assistant_messages( if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] @staticmethod def load_tool_requests(data: dict | list, context: LoadContext | None) -> list[ModelToolRequest]: + if context is None: + context = LoadContext(path="toolRequests") if isinstance(data, dict): # convert simple named toolRequests to list of ModelToolRequest result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(ModelToolRequest.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "id": v}) - data = result - return [ModelToolRequest.load(item, context) for item in data] + result.append(ModelToolRequest.load({"name": k, "id": v}, context.at(k))) + return result + return [ModelToolRequest.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_tool_requests( @@ -132,28 +145,8 @@ def save_tool_requests( if context is None: context = SaveContext() - if context.collection_format == "array": - return [item.save(context) for item in items] - - # Object format: use name as key - result: dict[str, Any] = {} - for item in items: - item_data = item.save(context) - name = item_data.pop("name", None) - if name: - # Check if we can use shorthand (only primary property set) - if context.use_shorthand and hasattr(item, "_shorthand_property"): - shorthand_prop = item._shorthand_property - if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: - result[name] = item_data[shorthand_prop] - continue - result[name] = item_data - else: - # No name, fall back to array format for this item - if "_unnamed" not in result: - result["_unnamed"] = [] - result["_unnamed"].append(item_data) - return result + # The schema declares an ordered collection, so preserve array format + return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: """Save the ModelInvocationResponse instance to a dictionary. diff --git a/runtime/python/prompty/prompty/model/pipeline/_ModelReconciliationState.py b/runtime/python/prompty/prompty/model/pipeline/_ModelReconciliationState.py index da6ba47eb..82ce74ca3 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_ModelReconciliationState.py +++ b/runtime/python/prompty/prompty/model/pipeline/_ModelReconciliationState.py @@ -52,11 +52,14 @@ def load(data: Any, context: LoadContext | None = None) -> "ModelReconciliationS """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ModelReconciliationState: {data}") + if "request" not in data or data["request"] is None: + raise ValueError(f"{context.at('request').path}: missing required field") # create new instance instance = ModelReconciliationState() @@ -64,7 +67,7 @@ def load(data: Any, context: LoadContext | None = None) -> "ModelReconciliationS if data is not None and "invocationId" in data: instance.invocation_id = data["invocationId"] if data is not None and "request" in data: - instance.request = ModelInvocationRequest.load(data["request"], context) + instance.request = ModelInvocationRequest.load(data["request"], context.at("request")) if data is not None and "failedAttempt" in data: instance.failed_attempt = data["failedAttempt"] if data is not None and "message" in data: diff --git a/runtime/python/prompty/prompty/model/pipeline/_ModelToolRequest.py b/runtime/python/prompty/prompty/model/pipeline/_ModelToolRequest.py index 9d064e643..0d855bfa4 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_ModelToolRequest.py +++ b/runtime/python/prompty/prompty/model/pipeline/_ModelToolRequest.py @@ -48,8 +48,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ModelToolRequest": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ModelToolRequest: {data}") diff --git a/runtime/python/prompty/prompty/model/pipeline/_ModelToolResult.py b/runtime/python/prompty/prompty/model/pipeline/_ModelToolResult.py index 24c058b7a..41127c62d 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_ModelToolResult.py +++ b/runtime/python/prompty/prompty/model/pipeline/_ModelToolResult.py @@ -53,8 +53,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ModelToolResult": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ModelToolResult: {data}") diff --git a/runtime/python/prompty/prompty/model/pipeline/_ReplayJournalRecord.py b/runtime/python/prompty/prompty/model/pipeline/_ReplayJournalRecord.py index 0ad265f0d..ba24c853b 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_ReplayJournalRecord.py +++ b/runtime/python/prompty/prompty/model/pipeline/_ReplayJournalRecord.py @@ -76,8 +76,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ReplayJournalRecord" """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ReplayJournalRecord: {data}") diff --git a/runtime/python/prompty/prompty/model/pipeline/_ReplayMismatch.py b/runtime/python/prompty/prompty/model/pipeline/_ReplayMismatch.py index da5b7e55c..9031a30f1 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_ReplayMismatch.py +++ b/runtime/python/prompty/prompty/model/pipeline/_ReplayMismatch.py @@ -46,8 +46,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ReplayMismatch": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ReplayMismatch: {data}") @@ -58,9 +59,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ReplayMismatch": if data is not None and "index" in data: instance.index = data["index"] if data is not None and "expected" in data: - instance.expected = ReplayJournalRecord.load(data["expected"], context) + instance.expected = ReplayJournalRecord.load(data["expected"], context.at("expected")) if data is not None and "actual" in data: - instance.actual = ReplayJournalRecord.load(data["actual"], context) + instance.actual = ReplayJournalRecord.load(data["actual"], context.at("actual")) if data is not None and "message" in data: instance.message = data["message"] if context is not None: diff --git a/runtime/python/prompty/prompty/model/pipeline/_ReplayVerificationRequest.py b/runtime/python/prompty/prompty/model/pipeline/_ReplayVerificationRequest.py index 04d54dd38..4d44d3c88 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_ReplayVerificationRequest.py +++ b/runtime/python/prompty/prompty/model/pipeline/_ReplayVerificationRequest.py @@ -40,8 +40,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ReplayVerificationRe """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ReplayVerificationRequest: {data}") @@ -50,27 +51,31 @@ def load(data: Any, context: LoadContext | None = None) -> "ReplayVerificationRe instance = ReplayVerificationRequest() if data is not None and "expected" in data: - instance.expected = ReplayVerificationRequest.load_expected(data["expected"], context) + instance.expected = ReplayVerificationRequest.load_expected(data["expected"], context.at("expected")) if data is not None and "actual" in data: - instance.actual = ReplayVerificationRequest.load_actual(data["actual"], context) + instance.actual = ReplayVerificationRequest.load_actual(data["actual"], context.at("actual")) if context is not None: instance = context.process_output(instance) return instance @staticmethod def load_expected(data: dict | list, context: LoadContext | None) -> list[ReplayJournalRecord]: + if context is None: + context = LoadContext(path="expected") if isinstance(data, dict): # convert simple named expected to list of ReplayJournalRecord result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(ReplayJournalRecord.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "kind": v}) - data = result - return [ReplayJournalRecord.load(item, context) for item in data] + result.append(ReplayJournalRecord.load({"name": k, "kind": v}, context.at(k))) + return result + return [ReplayJournalRecord.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_expected( @@ -79,23 +84,27 @@ def save_expected( if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] @staticmethod def load_actual(data: dict | list, context: LoadContext | None) -> list[ReplayJournalRecord]: + if context is None: + context = LoadContext(path="actual") if isinstance(data, dict): # convert simple named actual to list of ReplayJournalRecord result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(ReplayJournalRecord.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "kind": v}) - data = result - return [ReplayJournalRecord.load(item, context) for item in data] + result.append(ReplayJournalRecord.load({"name": k, "kind": v}, context.at(k))) + return result + return [ReplayJournalRecord.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_actual( @@ -104,7 +113,7 @@ def save_actual( if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/pipeline/_ReplayVerificationResult.py b/runtime/python/prompty/prompty/model/pipeline/_ReplayVerificationResult.py index 00fffe09c..90020c99b 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_ReplayVerificationResult.py +++ b/runtime/python/prompty/prompty/model/pipeline/_ReplayVerificationResult.py @@ -33,7 +33,7 @@ class ReplayVerificationResult: _shorthand_property: ClassVar[str | None] = None status: ReplayVerificationStatus = field(default="passed") - mismatches: list[ReplayMismatch] = field(default_factory=list) + mismatches: list[ReplayMismatch] | None = field(default_factory=list) expected_count: int = field(default=0) actual_count: int = field(default=0) @@ -48,8 +48,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ReplayVerificationRe """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ReplayVerificationResult: {data}") @@ -60,7 +61,7 @@ def load(data: Any, context: LoadContext | None = None) -> "ReplayVerificationRe if data is not None and "status" in data: instance.status = data["status"] if data is not None and "mismatches" in data: - instance.mismatches = ReplayVerificationResult.load_mismatches(data["mismatches"], context) + instance.mismatches = ReplayVerificationResult.load_mismatches(data["mismatches"], context.at("mismatches")) if data is not None and "expectedCount" in data: instance.expected_count = data["expectedCount"] if data is not None and "actualCount" in data: @@ -71,18 +72,22 @@ def load(data: Any, context: LoadContext | None = None) -> "ReplayVerificationRe @staticmethod def load_mismatches(data: dict | list, context: LoadContext | None) -> list[ReplayMismatch]: + if context is None: + context = LoadContext(path="mismatches") if isinstance(data, dict): # convert simple named mismatches to list of ReplayMismatch result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(ReplayMismatch.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "index": v}) - data = result - return [ReplayMismatch.load(item, context) for item in data] + result.append(ReplayMismatch.load({"name": k, "index": v}, context.at(k))) + return result + return [ReplayMismatch.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_mismatches( @@ -91,7 +96,7 @@ def save_mismatches( if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/pipeline/_ResumeContext.py b/runtime/python/prompty/prompty/model/pipeline/_ResumeContext.py index 5bdec8541..80bead842 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_ResumeContext.py +++ b/runtime/python/prompty/prompty/model/pipeline/_ResumeContext.py @@ -52,17 +52,20 @@ def load(data: Any, context: LoadContext | None = None) -> "ResumeContext": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ResumeContext: {data}") + if "checkpoint" not in data or data["checkpoint"] is None: + raise ValueError(f"{context.at('checkpoint').path}: missing required field") # create new instance instance = ResumeContext() if data is not None and "checkpoint" in data: - instance.checkpoint = EngineCheckpoint.load(data["checkpoint"], context) + instance.checkpoint = EngineCheckpoint.load(data["checkpoint"], context.at("checkpoint")) if data is not None and "maxIterations" in data: instance.max_iterations = data["maxIterations"] if data is not None and "maxModelAttempts" in data: diff --git a/runtime/python/prompty/prompty/model/pipeline/_RetryPolicyRequest.py b/runtime/python/prompty/prompty/model/pipeline/_RetryPolicyRequest.py index 646c351ae..7d81df12b 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_RetryPolicyRequest.py +++ b/runtime/python/prompty/prompty/model/pipeline/_RetryPolicyRequest.py @@ -45,8 +45,9 @@ def load(data: Any, context: LoadContext | None = None) -> "RetryPolicyRequest": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for RetryPolicyRequest: {data}") diff --git a/runtime/python/prompty/prompty/model/pipeline/_RunTurnRequest.py b/runtime/python/prompty/prompty/model/pipeline/_RunTurnRequest.py index 4e124b5b0..db691cf61 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_RunTurnRequest.py +++ b/runtime/python/prompty/prompty/model/pipeline/_RunTurnRequest.py @@ -23,7 +23,7 @@ class RunTurnRequest: turn_id : str Stable turn identifier within the session inputs : Optional[dict[str, Any]] - Inputs supplied to the deterministic single-turn run + Inputs supplied to the deterministic single-turn run. Values may be explicit null. options : Optional[TurnOptions] Canonical turn execution options """ @@ -46,8 +46,9 @@ def load(data: Any, context: LoadContext | None = None) -> "RunTurnRequest": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for RunTurnRequest: {data}") @@ -62,7 +63,7 @@ def load(data: Any, context: LoadContext | None = None) -> "RunTurnRequest": if data is not None and "inputs" in data: instance.inputs = data["inputs"] if data is not None and "options" in data: - instance.options = TurnOptions.load(data["options"], context) + instance.options = TurnOptions.load(data["options"], context.at("options")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/pipeline/_RunTurnResult.py b/runtime/python/prompty/prompty/model/pipeline/_RunTurnResult.py index 706caa83a..e5035398c 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_RunTurnResult.py +++ b/runtime/python/prompty/prompty/model/pipeline/_RunTurnResult.py @@ -44,8 +44,8 @@ class RunTurnResult: status: RunTurnStatus = field(default="success") output: Any | None = None iterations: int = field(default=0) - tool_results: list[HostToolResult] = field(default_factory=list) - checkpoints: list[Checkpoint] = field(default_factory=list) + tool_results: list[HostToolResult] | None = field(default_factory=list) + checkpoints: list[Checkpoint] | None = field(default_factory=list) @staticmethod def load(data: Any, context: LoadContext | None = None) -> "RunTurnResult": @@ -58,8 +58,9 @@ def load(data: Any, context: LoadContext | None = None) -> "RunTurnResult": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for RunTurnResult: {data}") @@ -78,27 +79,31 @@ def load(data: Any, context: LoadContext | None = None) -> "RunTurnResult": if data is not None and "iterations" in data: instance.iterations = data["iterations"] if data is not None and "toolResults" in data: - instance.tool_results = RunTurnResult.load_tool_results(data["toolResults"], context) + instance.tool_results = RunTurnResult.load_tool_results(data["toolResults"], context.at("toolResults")) if data is not None and "checkpoints" in data: - instance.checkpoints = RunTurnResult.load_checkpoints(data["checkpoints"], context) + instance.checkpoints = RunTurnResult.load_checkpoints(data["checkpoints"], context.at("checkpoints")) if context is not None: instance = context.process_output(instance) return instance @staticmethod def load_tool_results(data: dict | list, context: LoadContext | None) -> list[HostToolResult]: + if context is None: + context = LoadContext(path="toolResults") if isinstance(data, dict): # convert simple named toolResults to list of HostToolResult result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(HostToolResult.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "requestId": v}) - data = result - return [HostToolResult.load(item, context) for item in data] + result.append(HostToolResult.load({"name": k, "requestId": v}, context.at(k))) + return result + return [HostToolResult.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_tool_results( @@ -107,30 +112,34 @@ def save_tool_results( if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] @staticmethod def load_checkpoints(data: dict | list, context: LoadContext | None) -> list[Checkpoint]: + if context is None: + context = LoadContext(path="checkpoints") if isinstance(data, dict): # convert simple named checkpoints to list of Checkpoint result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Checkpoint.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "id": v}) - data = result - return [Checkpoint.load(item, context) for item in data] + result.append(Checkpoint.load({"name": k, "id": v}, context.at(k))) + return result + return [Checkpoint.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_checkpoints(items: list[Checkpoint], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/pipeline/_TurnCommit.py b/runtime/python/prompty/prompty/model/pipeline/_TurnCommit.py index 9f62c27e3..54314e002 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_TurnCommit.py +++ b/runtime/python/prompty/prompty/model/pipeline/_TurnCommit.py @@ -65,11 +65,14 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnCommit": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TurnCommit: {data}") + if "contextState" not in data or data["contextState"] is None: + raise ValueError(f"{context.at('contextState').path}: missing required field") # create new instance instance = TurnCommit() @@ -83,40 +86,46 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnCommit": if data is not None and "output" in data: instance.output = data["output"] if data is not None and "messages" in data: - instance.messages = TurnCommit.load_messages(data["messages"], context) + instance.messages = TurnCommit.load_messages(data["messages"], context.at("messages")) if data is not None and "iterations" in data: instance.iterations = data["iterations"] if data is not None and "lastSequence" in data: instance.last_sequence = data["lastSequence"] if data is not None and "contextState" in data: - instance.context_state = InvocationContextState.load(data["contextState"], context) + instance.context_state = InvocationContextState.load(data["contextState"], context.at("contextState")) if data is not None and "modelReconciliation" in data: - instance.model_reconciliation = ModelReconciliationState.load(data["modelReconciliation"], context) + instance.model_reconciliation = ModelReconciliationState.load( + data["modelReconciliation"], context.at("modelReconciliation") + ) if context is not None: instance = context.process_output(instance) return instance @staticmethod def load_messages(data: dict | list, context: LoadContext | None) -> list[Message]: + if context is None: + context = LoadContext(path="messages") if isinstance(data, dict): # convert simple named messages to list of Message result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Message.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "role": v}) - data = result - return [Message.load(item, context) for item in data] + result.append(Message.load({"name": k, "role": v}, context.at(k))) + return result + return [Message.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_messages(items: list[Message], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/pipeline/_TurnEngineResult.py b/runtime/python/prompty/prompty/model/pipeline/_TurnEngineResult.py index 4325d7c4e..73657e629 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_TurnEngineResult.py +++ b/runtime/python/prompty/prompty/model/pipeline/_TurnEngineResult.py @@ -33,8 +33,8 @@ class TurnEngineResult: _shorthand_property: ClassVar[str | None] = None commit: TurnCommit = field(default_factory=TurnCommit) - snapshots: list[ModelInvocationContextSnapshot] = field(default_factory=list) - tool_results: list[ModelToolResult] = field(default_factory=list) + snapshots: list[ModelInvocationContextSnapshot] | None = field(default_factory=list) + tool_results: list[ModelToolResult] | None = field(default_factory=list) post_commit_error: str | None = None @staticmethod @@ -48,21 +48,24 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnEngineResult": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TurnEngineResult: {data}") + if "commit" not in data or data["commit"] is None: + raise ValueError(f"{context.at('commit').path}: missing required field") # create new instance instance = TurnEngineResult() if data is not None and "commit" in data: - instance.commit = TurnCommit.load(data["commit"], context) + instance.commit = TurnCommit.load(data["commit"], context.at("commit")) if data is not None and "snapshots" in data: - instance.snapshots = TurnEngineResult.load_snapshots(data["snapshots"], context) + instance.snapshots = TurnEngineResult.load_snapshots(data["snapshots"], context.at("snapshots")) if data is not None and "toolResults" in data: - instance.tool_results = TurnEngineResult.load_tool_results(data["toolResults"], context) + instance.tool_results = TurnEngineResult.load_tool_results(data["toolResults"], context.at("toolResults")) if data is not None and "postCommitError" in data: instance.post_commit_error = data["postCommitError"] if context is not None: @@ -71,18 +74,22 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnEngineResult": @staticmethod def load_snapshots(data: dict | list, context: LoadContext | None) -> list[ModelInvocationContextSnapshot]: + if context is None: + context = LoadContext(path="snapshots") if isinstance(data, dict): # convert simple named snapshots to list of ModelInvocationContextSnapshot result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(ModelInvocationContextSnapshot.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "id": v}) - data = result - return [ModelInvocationContextSnapshot.load(item, context) for item in data] + result.append(ModelInvocationContextSnapshot.load({"name": k, "id": v}, context.at(k))) + return result + return [ModelInvocationContextSnapshot.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_snapshots( @@ -91,23 +98,27 @@ def save_snapshots( if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] @staticmethod def load_tool_results(data: dict | list, context: LoadContext | None) -> list[ModelToolResult]: + if context is None: + context = LoadContext(path="toolResults") if isinstance(data, dict): # convert simple named toolResults to list of ModelToolResult result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(ModelToolResult.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "requestId": v}) - data = result - return [ModelToolResult.load(item, context) for item in data] + result.append(ModelToolResult.load({"name": k, "requestId": v}, context.at(k))) + return result + return [ModelToolResult.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_tool_results( @@ -116,28 +127,8 @@ def save_tool_results( if context is None: context = SaveContext() - if context.collection_format == "array": - return [item.save(context) for item in items] - - # Object format: use name as key - result: dict[str, Any] = {} - for item in items: - item_data = item.save(context) - name = item_data.pop("name", None) - if name: - # Check if we can use shorthand (only primary property set) - if context.use_shorthand and hasattr(item, "_shorthand_property"): - shorthand_prop = item._shorthand_property - if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: - result[name] = item_data[shorthand_prop] - continue - result[name] = item_data - else: - # No name, fall back to array format for this item - if "_unnamed" not in result: - result["_unnamed"] = [] - result["_unnamed"].append(item_data) - return result + # The schema declares an ordered collection, so preserve array format + return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: """Save the TurnEngineResult instance to a dictionary. diff --git a/runtime/python/prompty/prompty/model/pipeline/_TurnModelRequest.py b/runtime/python/prompty/prompty/model/pipeline/_TurnModelRequest.py index 2bb23453d..bcda9e666 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_TurnModelRequest.py +++ b/runtime/python/prompty/prompty/model/pipeline/_TurnModelRequest.py @@ -29,7 +29,7 @@ class TurnModelRequest: iteration : int Zero-based model loop iteration inputs : Optional[dict[str, Any]] - Inputs supplied to the deterministic single-turn run + Inputs supplied to the deterministic single-turn run. Values may be explicit null. options : Optional[TurnOptions] Canonical turn execution options tool_results : Optional[list[HostToolResult]] @@ -43,7 +43,7 @@ class TurnModelRequest: iteration: int = field(default=0) inputs: dict[str, Any] | None = None options: TurnOptions | None = None - tool_results: list[HostToolResult] = field(default_factory=list) + tool_results: list[HostToolResult] | None = field(default_factory=list) @staticmethod def load(data: Any, context: LoadContext | None = None) -> "TurnModelRequest": @@ -56,8 +56,9 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnModelRequest": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TurnModelRequest: {data}") @@ -74,27 +75,31 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnModelRequest": if data is not None and "inputs" in data: instance.inputs = data["inputs"] if data is not None and "options" in data: - instance.options = TurnOptions.load(data["options"], context) + instance.options = TurnOptions.load(data["options"], context.at("options")) if data is not None and "toolResults" in data: - instance.tool_results = TurnModelRequest.load_tool_results(data["toolResults"], context) + instance.tool_results = TurnModelRequest.load_tool_results(data["toolResults"], context.at("toolResults")) if context is not None: instance = context.process_output(instance) return instance @staticmethod def load_tool_results(data: dict | list, context: LoadContext | None) -> list[HostToolResult]: + if context is None: + context = LoadContext(path="toolResults") if isinstance(data, dict): # convert simple named toolResults to list of HostToolResult result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(HostToolResult.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "requestId": v}) - data = result - return [HostToolResult.load(item, context) for item in data] + result.append(HostToolResult.load({"name": k, "requestId": v}, context.at(k))) + return result + return [HostToolResult.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_tool_results( @@ -103,7 +108,7 @@ def save_tool_results( if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/pipeline/_TurnModelResponse.py b/runtime/python/prompty/prompty/model/pipeline/_TurnModelResponse.py index e93347812..0c92ca981 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_TurnModelResponse.py +++ b/runtime/python/prompty/prompty/model/pipeline/_TurnModelResponse.py @@ -26,14 +26,14 @@ class TurnModelResponse: tool_requests : Optional[list[HostToolRequest]] Host tool execution requests emitted by the model callback checkpoint_state : Optional[dict[str, Any]] - Additional deterministic state to merge into the iteration checkpoint + Additional deterministic state to merge into the iteration checkpoint. Values may be explicit null. """ _shorthand_property: ClassVar[str | None] = None output: Any | None = None usage: InvocationUsage | None = None - tool_requests: list[HostToolRequest] = field(default_factory=list) + tool_requests: list[HostToolRequest] | None = field(default_factory=list) checkpoint_state: dict[str, Any] | None = None @staticmethod @@ -47,8 +47,9 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnModelResponse": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TurnModelResponse: {data}") @@ -59,9 +60,11 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnModelResponse": if data is not None and "output" in data: instance.output = data["output"] if data is not None and "usage" in data: - instance.usage = InvocationUsage.load(data["usage"], context) + instance.usage = InvocationUsage.load(data["usage"], context.at("usage")) if data is not None and "toolRequests" in data: - instance.tool_requests = TurnModelResponse.load_tool_requests(data["toolRequests"], context) + instance.tool_requests = TurnModelResponse.load_tool_requests( + data["toolRequests"], context.at("toolRequests") + ) if data is not None and "checkpointState" in data: instance.checkpoint_state = data["checkpointState"] if context is not None: @@ -70,18 +73,22 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnModelResponse": @staticmethod def load_tool_requests(data: dict | list, context: LoadContext | None) -> list[HostToolRequest]: + if context is None: + context = LoadContext(path="toolRequests") if isinstance(data, dict): # convert simple named toolRequests to list of HostToolRequest result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(HostToolRequest.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "requestId": v}) - data = result - return [HostToolRequest.load(item, context) for item in data] + result.append(HostToolRequest.load({"name": k, "requestId": v}, context.at(k))) + return result + return [HostToolRequest.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_tool_requests( @@ -90,7 +97,7 @@ def save_tool_requests( if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/pipeline/_TurnOptions.py b/runtime/python/prompty/prompty/model/pipeline/_TurnOptions.py index be8fdf9db..939caf7ef 100644 --- a/runtime/python/prompty/prompty/model/pipeline/_TurnOptions.py +++ b/runtime/python/prompty/prompty/model/pipeline/_TurnOptions.py @@ -60,8 +60,9 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnOptions": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TurnOptions: {data}") @@ -82,7 +83,7 @@ def load(data: Any, context: LoadContext | None = None) -> "TurnOptions": if data is not None and "turn" in data: instance.turn = data["turn"] if data is not None and "compaction" in data: - instance.compaction = CompactionConfig.load(data["compaction"], context) + instance.compaction = CompactionConfig.load(data["compaction"], context.at("compaction")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/pipeline/__init__.py b/runtime/python/prompty/prompty/model/pipeline/__init__.py index a1e9101a2..512b8d927 100644 --- a/runtime/python/prompty/prompty/model/pipeline/__init__.py +++ b/runtime/python/prompty/prompty/model/pipeline/__init__.py @@ -10,8 +10,12 @@ from ._ContextRequest import ContextRequest from ._DelegatedStateReference import DelegatedStateReference from ._EngineCheckpoint import EngineCheckpoint +from ._EngineDurabilityPort import EngineDurabilityPort from ._EngineEvent import EngineEvent from ._EnginePermissionDecision import EnginePermissionDecision +from ._EnginePermissionPort import EnginePermissionPort +from ._EnginePostCommitPort import EnginePostCommitPort +from ._EngineToolPort import EngineToolPort from ._EventJournalWriter import EventJournalWriter from ._EventSink import EventSink from ._Executor import Executor @@ -62,6 +66,10 @@ "ResumeContext", "TurnCommit", "TurnEngineResult", + "EnginePermissionPort", + "EngineToolPort", + "EngineDurabilityPort", + "EnginePostCommitPort", "HostPolicyRequest", "HostPolicyResult", "FinalOutputPolicyRequest", diff --git a/runtime/python/prompty/prompty/model/streaming/_StreamOptions.py b/runtime/python/prompty/prompty/model/streaming/_StreamOptions.py index 6400bbd21..485b817c8 100644 --- a/runtime/python/prompty/prompty/model/streaming/_StreamOptions.py +++ b/runtime/python/prompty/prompty/model/streaming/_StreamOptions.py @@ -37,8 +37,9 @@ def load(data: Any, context: LoadContext | None = None) -> "StreamOptions": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for StreamOptions: {data}") diff --git a/runtime/python/prompty/prompty/model/template/_FormatConfig.py b/runtime/python/prompty/prompty/model/template/_FormatConfig.py index 4f80e68a1..0162a6a15 100644 --- a/runtime/python/prompty/prompty/model/template/_FormatConfig.py +++ b/runtime/python/prompty/prompty/model/template/_FormatConfig.py @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "FormatConfig": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) # handle alternate representations if isinstance(data, str): diff --git a/runtime/python/prompty/prompty/model/template/_ParserConfig.py b/runtime/python/prompty/prompty/model/template/_ParserConfig.py index 1bb897c3b..8418bbb33 100644 --- a/runtime/python/prompty/prompty/model/template/_ParserConfig.py +++ b/runtime/python/prompty/prompty/model/template/_ParserConfig.py @@ -39,8 +39,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ParserConfig": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) # handle alternate representations if isinstance(data, str): diff --git a/runtime/python/prompty/prompty/model/template/_Template.py b/runtime/python/prompty/prompty/model/template/_Template.py index 577260a88..dd16b8060 100644 --- a/runtime/python/prompty/prompty/model/template/_Template.py +++ b/runtime/python/prompty/prompty/model/template/_Template.py @@ -48,19 +48,24 @@ def load(data: Any, context: LoadContext | None = None) -> "Template": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for Template: {data}") + if "format" not in data or data["format"] is None: + raise ValueError(f"{context.at('format').path}: missing required field") + if "parser" not in data or data["parser"] is None: + raise ValueError(f"{context.at('parser').path}: missing required field") # create new instance instance = Template() if data is not None and "format" in data: - instance.format = FormatConfig.load(data["format"], context) + instance.format = FormatConfig.load(data["format"], context.at("format")) if data is not None and "parser" in data: - instance.parser = ParserConfig.load(data["parser"], context) + instance.parser = ParserConfig.load(data["parser"], context.at("parser")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/tools/_Binding.py b/runtime/python/prompty/prompty/model/tools/_Binding.py index 8b834581e..5c784effc 100644 --- a/runtime/python/prompty/prompty/model/tools/_Binding.py +++ b/runtime/python/prompty/prompty/model/tools/_Binding.py @@ -39,8 +39,9 @@ def load(data: Any, context: LoadContext | None = None) -> "Binding": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) # handle alternate representations if isinstance(data, str): diff --git a/runtime/python/prompty/prompty/model/tools/_McpApprovalMode.py b/runtime/python/prompty/prompty/model/tools/_McpApprovalMode.py index a4f9ba9b0..24b8636e4 100644 --- a/runtime/python/prompty/prompty/model/tools/_McpApprovalMode.py +++ b/runtime/python/prompty/prompty/model/tools/_McpApprovalMode.py @@ -32,8 +32,8 @@ class McpApprovalMode: _shorthand_property: ClassVar[str | None] = "kind" kind: mcpApprovalModeKind = field(default="always") - always_require_approval_tools: list[str] = field(default_factory=list) - never_require_approval_tools: list[str] = field(default_factory=list) + always_require_approval_tools: list[str] | None = None + never_require_approval_tools: list[str] | None = None @staticmethod def load(data: Any, context: LoadContext | None = None) -> "McpApprovalMode": @@ -46,8 +46,9 @@ def load(data: Any, context: LoadContext | None = None) -> "McpApprovalMode": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) # handle alternate representations if isinstance(data, str): diff --git a/runtime/python/prompty/prompty/model/tools/_Tool.py b/runtime/python/prompty/prompty/model/tools/_Tool.py index aac3a6d18..bd28ec647 100644 --- a/runtime/python/prompty/prompty/model/tools/_Tool.py +++ b/runtime/python/prompty/prompty/model/tools/_Tool.py @@ -37,7 +37,7 @@ class Tool(ABC): name: str = field(default="") kind: str = field(default="") description: str | None = None - bindings: list[Binding] = field(default_factory=list) + bindings: list[Binding] | None = field(default_factory=list) @staticmethod def load(data: Any, context: LoadContext | None = None) -> "Tool": @@ -50,8 +50,9 @@ def load(data: Any, context: LoadContext | None = None) -> "Tool": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for Tool: {data}") @@ -66,59 +67,68 @@ def load(data: Any, context: LoadContext | None = None) -> "Tool": if data is not None and "description" in data: instance.description = data["description"] if data is not None and "bindings" in data: - instance.bindings = Tool.load_bindings(data["bindings"], context) + instance.bindings = Tool.load_bindings(data["bindings"], context.at("bindings")) if context is not None: instance = context.process_output(instance) return instance @staticmethod def load_bindings(data: dict | list, context: LoadContext | None) -> list[Binding]: + if context is None: + context = LoadContext(path="bindings") if isinstance(data, dict): # convert simple named bindings to list of Binding result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Binding.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "input": v}) - data = result - return [Binding.load(item, context) for item in data] + result.append(Binding.load({"name": k, "input": v}, context.at(k))) + return result + return [Binding.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_bindings(items: list[Binding], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() + serialized = [dict(item.save(context)) for item in items] + for item_data in serialized: + if item_data.get("name") == "": + item_data.pop("name") + if context.collection_format == "array": - return [item.save(context) for item in items] + return serialized + + names: set[str] = set() + for item_data in serialized: + name = item_data.get("name") + if not isinstance(name, str) or not name or name in names: + return serialized + names.add(name) # Object format: use name as key result: dict[str, Any] = {} - for item in items: - item_data = item.save(context) - name = item_data.pop("name", None) - if name: - # Check if we can use shorthand (only primary property set) - if context.use_shorthand and hasattr(item, "_shorthand_property"): - shorthand_prop = item._shorthand_property - if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: - result[name] = item_data[shorthand_prop] - continue - result[name] = item_data - else: - # No name, fall back to array format for this item - if "_unnamed" not in result: - result["_unnamed"] = [] - result["_unnamed"].append(item_data) + for item, item_data in zip(items, serialized): + name = item_data.pop("name") + # Check if we can use shorthand (only primary property set) + if context.use_shorthand and hasattr(item, "_shorthand_property"): + shorthand_prop = item._shorthand_property + if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: + result[name] = item_data[shorthand_prop] + continue + result[name] = item_data return result @staticmethod def load_kind(data: dict, context: LoadContext | None) -> "Tool": # load polymorphic Tool instance if data is not None and "kind" in data: - discriminator_value = str(data["kind"]).lower() + discriminator_value = str(data["kind"]) if discriminator_value == "function": return FunctionTool.load(data, context) elif discriminator_value == "mcp": @@ -218,8 +228,9 @@ def load(data: Any, context: LoadContext | None = None) -> "FunctionTool": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for FunctionTool: {data}") @@ -230,7 +241,7 @@ def load(data: Any, context: LoadContext | None = None) -> "FunctionTool": if data is not None and "kind" in data: instance.kind = data["kind"] if data is not None and "parameters" in data: - instance.parameters = FunctionTool.load_parameters(data["parameters"], context) + instance.parameters = FunctionTool.load_parameters(data["parameters"], context.at("parameters")) if data is not None and "strict" in data: instance.strict = data["strict"] if context is not None: @@ -239,45 +250,64 @@ def load(data: Any, context: LoadContext | None = None) -> "FunctionTool": @staticmethod def load_parameters(data: dict | list, context: LoadContext | None) -> list[Property]: + if context is None: + context = LoadContext(path="parameters") if isinstance(data, dict): # convert simple named parameters to list of Property result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Property.load({"name": k, **v}, context.at(k))) else: - # value is a scalar, use it as the primary property - result.append({"name": k, "kind": v}) - data = result - return [Property.load(item, context) for item in data] + # value is a scalar, infer the entry shape from its type + if isinstance(v, int) and not isinstance(v, bool): + shorthand = {"kind": "integer", "default": v} + elif isinstance(v, float): + shorthand = {"kind": "float", "default": v} + elif isinstance(v, str): + shorthand = {"kind": "string", "default": v} + elif isinstance(v, bool): + shorthand = {"kind": "boolean", "default": v} + else: + shorthand = {"default": v} + result.append(Property.load({"name": k, **shorthand}, context.at(k))) + return result + return [Property.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_parameters(items: list[Property], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() + serialized = [dict(item.save(context)) for item in items] + for item_data in serialized: + if item_data.get("name") == "": + item_data.pop("name") + if context.collection_format == "array": - return [item.save(context) for item in items] + return serialized + + names: set[str] = set() + for item_data in serialized: + name = item_data.get("name") + if not isinstance(name, str) or not name or name in names: + return serialized + names.add(name) # Object format: use name as key result: dict[str, Any] = {} - for item in items: - item_data = item.save(context) - name = item_data.pop("name", None) - if name: - # Check if we can use shorthand (only primary property set) - if context.use_shorthand and hasattr(item, "_shorthand_property"): - shorthand_prop = item._shorthand_property - if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: - result[name] = item_data[shorthand_prop] - continue - result[name] = item_data - else: - # No name, fall back to array format for this item - if "_unnamed" not in result: - result["_unnamed"] = [] - result["_unnamed"].append(item_data) + for item, item_data in zip(items, serialized): + name = item_data.pop("name") + # Check if we can use shorthand (only primary property set) + if context.use_shorthand and hasattr(item, "_shorthand_property"): + shorthand_prop = item._shorthand_property + if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: + result[name] = item_data[shorthand_prop] + continue + result[name] = item_data return result def save(self, context: SaveContext | None = None) -> dict[str, Any]: @@ -364,11 +394,18 @@ def load(data: Any, context: LoadContext | None = None) -> "CustomTool": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for CustomTool: {data}") + if ( + isinstance(data.get("kind"), str) + and data["kind"] != "" + and ("connection" not in data or data["connection"] is None) + ): + raise ValueError(f"{context.at('connection').path}: missing required field") # create new instance instance = CustomTool() @@ -376,7 +413,7 @@ def load(data: Any, context: LoadContext | None = None) -> "CustomTool": if data is not None and "kind" in data: instance.kind = data["kind"] if data is not None and "connection" in data: - instance.connection = Connection.load(data["connection"], context) + instance.connection = Connection.load(data["connection"], context.at("connection")) if data is not None and "options" in data: instance.options = data["options"] if context is not None: @@ -446,7 +483,7 @@ class McpTool(Tool): The server name of the MCP tool server_description : Optional[str] The description of the MCP tool - approval_mode : McpApprovalMode + approval_mode : Optional[McpApprovalMode] The approval mode for the MCP tool allowed_tools : Optional[list[str]] List of allowed operations or resources for the MCP tool @@ -458,8 +495,8 @@ class McpTool(Tool): connection: Connection = field(default_factory=Connection) server_name: str = field(default="") server_description: str | None = None - approval_mode: McpApprovalMode = field(default_factory=McpApprovalMode) - allowed_tools: list[str] = field(default_factory=list) + approval_mode: McpApprovalMode | None = None + allowed_tools: list[str] | None = None @staticmethod def load(data: Any, context: LoadContext | None = None) -> "McpTool": @@ -472,11 +509,14 @@ def load(data: Any, context: LoadContext | None = None) -> "McpTool": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for McpTool: {data}") + if "connection" not in data or data["connection"] is None: + raise ValueError(f"{context.at('connection').path}: missing required field") # create new instance instance = McpTool() @@ -484,13 +524,13 @@ def load(data: Any, context: LoadContext | None = None) -> "McpTool": if data is not None and "kind" in data: instance.kind = data["kind"] if data is not None and "connection" in data: - instance.connection = Connection.load(data["connection"], context) + instance.connection = Connection.load(data["connection"], context.at("connection")) if data is not None and "serverName" in data: instance.server_name = data["serverName"] if data is not None and "serverDescription" in data: instance.server_description = data["serverDescription"] if data is not None and "approvalMode" in data: - instance.approval_mode = McpApprovalMode.load(data["approvalMode"], context) + instance.approval_mode = McpApprovalMode.load(data["approvalMode"], context.at("approvalMode")) if data is not None and "allowedTools" in data: instance.allowed_tools = data["allowedTools"] if context is not None: @@ -583,11 +623,14 @@ def load(data: Any, context: LoadContext | None = None) -> "OpenApiTool": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for OpenApiTool: {data}") + if "connection" not in data or data["connection"] is None: + raise ValueError(f"{context.at('connection').path}: missing required field") # create new instance instance = OpenApiTool() @@ -595,7 +638,7 @@ def load(data: Any, context: LoadContext | None = None) -> "OpenApiTool": if data is not None and "kind" in data: instance.kind = data["kind"] if data is not None and "connection" in data: - instance.connection = Connection.load(data["connection"], context) + instance.connection = Connection.load(data["connection"], context.at("connection")) if data is not None and "specification" in data: instance.specification = data["specification"] if context is not None: @@ -685,8 +728,9 @@ def load(data: Any, context: LoadContext | None = None) -> "PromptyTool": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for PromptyTool: {data}") diff --git a/runtime/python/prompty/prompty/model/tools/_ToolContext.py b/runtime/python/prompty/prompty/model/tools/_ToolContext.py index e79292c96..83fbaf575 100644 --- a/runtime/python/prompty/prompty/model/tools/_ToolContext.py +++ b/runtime/python/prompty/prompty/model/tools/_ToolContext.py @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolContext": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ToolContext: {data}") @@ -52,7 +53,7 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolContext": instance = ToolContext() if data is not None and "messages" in data: - instance.messages = ToolContext.load_messages(data["messages"], context) + instance.messages = ToolContext.load_messages(data["messages"], context.at("messages")) if data is not None and "metadata" in data: instance.metadata = data["metadata"] if context is not None: @@ -61,25 +62,29 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolContext": @staticmethod def load_messages(data: dict | list, context: LoadContext | None) -> list[Message]: + if context is None: + context = LoadContext(path="messages") if isinstance(data, dict): # convert simple named messages to list of Message result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(Message.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "role": v}) - data = result - return [Message.load(item, context) for item in data] + result.append(Message.load({"name": k, "role": v}, context.at(k))) + return result + return [Message.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_messages(items: list[Message], context: SaveContext | None) -> dict[str, Any] | list[dict[str, Any]]: if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: diff --git a/runtime/python/prompty/prompty/model/tools/_ToolDispatchResult.py b/runtime/python/prompty/prompty/model/tools/_ToolDispatchResult.py index cbc16f9df..647b2a428 100644 --- a/runtime/python/prompty/prompty/model/tools/_ToolDispatchResult.py +++ b/runtime/python/prompty/prompty/model/tools/_ToolDispatchResult.py @@ -45,11 +45,14 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolDispatchResult": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for ToolDispatchResult: {data}") + if "result" not in data or data["result"] is None: + raise ValueError(f"{context.at('result').path}: missing required field") # create new instance instance = ToolDispatchResult() @@ -59,7 +62,7 @@ def load(data: Any, context: LoadContext | None = None) -> "ToolDispatchResult": if data is not None and "name" in data: instance.name = data["name"] if data is not None and "result" in data: - instance.result = ToolResult.load(data["result"], context) + instance.result = ToolResult.load(data["result"], context.at("result")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/tracing/_TraceFile.py b/runtime/python/prompty/prompty/model/tracing/_TraceFile.py index ba4a79e7d..4d7c8e8ac 100644 --- a/runtime/python/prompty/prompty/model/tracing/_TraceFile.py +++ b/runtime/python/prompty/prompty/model/tracing/_TraceFile.py @@ -43,11 +43,14 @@ def load(data: Any, context: LoadContext | None = None) -> "TraceFile": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TraceFile: {data}") + if "trace" not in data or data["trace"] is None: + raise ValueError(f"{context.at('trace').path}: missing required field") # create new instance instance = TraceFile() @@ -57,7 +60,7 @@ def load(data: Any, context: LoadContext | None = None) -> "TraceFile": if data is not None and "version" in data: instance.version = data["version"] if data is not None and "trace" in data: - instance.trace = TraceSpan.load(data["trace"], context) + instance.trace = TraceSpan.load(data["trace"], context.at("trace")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/tracing/_TraceSpan.py b/runtime/python/prompty/prompty/model/tracing/_TraceSpan.py index 5bb42ad50..d41002611 100644 --- a/runtime/python/prompty/prompty/model/tracing/_TraceSpan.py +++ b/runtime/python/prompty/prompty/model/tracing/_TraceSpan.py @@ -51,7 +51,7 @@ class TraceSpan: error: str | None = None __usage: TokenUsage | None = None attributes: dict[str, Any] | None = None - __frames: list[Any] = field(default_factory=list) + __frames: list[Any] | None = None @staticmethod def load(data: Any, context: LoadContext | None = None) -> "TraceSpan": @@ -64,11 +64,14 @@ def load(data: Any, context: LoadContext | None = None) -> "TraceSpan": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TraceSpan: {data}") + if "__time" not in data or data["__time"] is None: + raise ValueError(f"{context.at('__time').path}: missing required field") # create new instance instance = TraceSpan() @@ -76,7 +79,7 @@ def load(data: Any, context: LoadContext | None = None) -> "TraceSpan": if data is not None and "name" in data: instance.name = data["name"] if data is not None and "__time" in data: - instance.__time = TraceTime.load(data["__time"], context) + instance.__time = TraceTime.load(data["__time"], context.at("__time")) if data is not None and "signature" in data: instance.signature = data["signature"] if data is not None and "inputs" in data: @@ -86,7 +89,7 @@ def load(data: Any, context: LoadContext | None = None) -> "TraceSpan": if data is not None and "error" in data: instance.error = data["error"] if data is not None and "__usage" in data: - instance.__usage = TokenUsage.load(data["__usage"], context) + instance.__usage = TokenUsage.load(data["__usage"], context.at("__usage")) if data is not None and "attributes" in data: instance.attributes = data["attributes"] if data is not None and "__frames" in data: diff --git a/runtime/python/prompty/prompty/model/tracing/_TraceTime.py b/runtime/python/prompty/prompty/model/tracing/_TraceTime.py index 85c5fbd39..574e6b03a 100644 --- a/runtime/python/prompty/prompty/model/tracing/_TraceTime.py +++ b/runtime/python/prompty/prompty/model/tracing/_TraceTime.py @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "TraceTime": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for TraceTime: {data}") diff --git a/runtime/python/prompty/prompty/model/wire/_AnthropicImageBlock.py b/runtime/python/prompty/prompty/model/wire/_AnthropicImageBlock.py index f2a2a3d1b..ccfc1ddd7 100644 --- a/runtime/python/prompty/prompty/model/wire/_AnthropicImageBlock.py +++ b/runtime/python/prompty/prompty/model/wire/_AnthropicImageBlock.py @@ -41,11 +41,14 @@ def load(data: Any, context: LoadContext | None = None) -> "AnthropicImageBlock" """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for AnthropicImageBlock: {data}") + if "source" not in data or data["source"] is None: + raise ValueError(f"{context.at('source').path}: missing required field") # create new instance instance = AnthropicImageBlock() @@ -53,7 +56,7 @@ def load(data: Any, context: LoadContext | None = None) -> "AnthropicImageBlock" if data is not None and "type" in data: instance.type = data["type"] if data is not None and "source" in data: - instance.source = AnthropicImageSource.load(data["source"], context) + instance.source = AnthropicImageSource.load(data["source"], context.at("source")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/wire/_AnthropicImageSource.py b/runtime/python/prompty/prompty/model/wire/_AnthropicImageSource.py index 4305990ac..51e4e54e8 100644 --- a/runtime/python/prompty/prompty/model/wire/_AnthropicImageSource.py +++ b/runtime/python/prompty/prompty/model/wire/_AnthropicImageSource.py @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "AnthropicImageSource """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for AnthropicImageSource: {data}") diff --git a/runtime/python/prompty/prompty/model/wire/_AnthropicMessagesRequest.py b/runtime/python/prompty/prompty/model/wire/_AnthropicMessagesRequest.py index 760dc4be5..c89dc58d2 100644 --- a/runtime/python/prompty/prompty/model/wire/_AnthropicMessagesRequest.py +++ b/runtime/python/prompty/prompty/model/wire/_AnthropicMessagesRequest.py @@ -48,8 +48,8 @@ class AnthropicMessagesRequest: temperature: float | None = None top_p: float | None = None top_k: int | None = None - stop_sequences: list[str] = field(default_factory=list) - tools: list[AnthropicToolDefinition] = field(default_factory=list) + stop_sequences: list[str] | None = None + tools: list[AnthropicToolDefinition] | None = None @staticmethod def load(data: Any, context: LoadContext | None = None) -> "AnthropicMessagesRequest": @@ -62,8 +62,9 @@ def load(data: Any, context: LoadContext | None = None) -> "AnthropicMessagesReq """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for AnthropicMessagesRequest: {data}") @@ -74,7 +75,7 @@ def load(data: Any, context: LoadContext | None = None) -> "AnthropicMessagesReq if data is not None and "model" in data: instance.model = data["model"] if data is not None and "messages" in data: - instance.messages = AnthropicMessagesRequest.load_messages(data["messages"], context) + instance.messages = AnthropicMessagesRequest.load_messages(data["messages"], context.at("messages")) if data is not None and "max_tokens" in data: instance.max_tokens = data["max_tokens"] if data is not None and "system" in data: @@ -88,25 +89,29 @@ def load(data: Any, context: LoadContext | None = None) -> "AnthropicMessagesReq if data is not None and "stop_sequences" in data: instance.stop_sequences = data["stop_sequences"] if data is not None and "tools" in data: - instance.tools = AnthropicMessagesRequest.load_tools(data["tools"], context) + instance.tools = AnthropicMessagesRequest.load_tools(data["tools"], context.at("tools")) if context is not None: instance = context.process_output(instance) return instance @staticmethod def load_messages(data: dict | list, context: LoadContext | None) -> list[AnthropicWireMessage]: + if context is None: + context = LoadContext(path="messages") if isinstance(data, dict): # convert simple named messages to list of AnthropicWireMessage result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(AnthropicWireMessage.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "role": v}) - data = result - return [AnthropicWireMessage.load(item, context) for item in data] + result.append(AnthropicWireMessage.load({"name": k, "role": v}, context.at(k))) + return result + return [AnthropicWireMessage.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_messages( @@ -115,23 +120,27 @@ def save_messages( if context is None: context = SaveContext() - # This type doesn't have a 'name' property, so always use array format + # The schema declares an ordered collection, so preserve array format return [item.save(context) for item in items] @staticmethod def load_tools(data: dict | list, context: LoadContext | None) -> list[AnthropicToolDefinition]: + if context is None: + context = LoadContext(path="tools") if isinstance(data, dict): # convert simple named tools to list of AnthropicToolDefinition result = [] for k, v in data.items(): + if isinstance(v, list): + raise TypeError(f"{context.at(k).path}: invalid named collection entry category array") if isinstance(v, dict): # value is an object, spread its properties - result.append({"name": k, **v}) + result.append(AnthropicToolDefinition.load({"name": k, **v}, context.at(k))) else: # value is a scalar, use it as the primary property - result.append({"name": k, "description": v}) - data = result - return [AnthropicToolDefinition.load(item, context) for item in data] + result.append(AnthropicToolDefinition.load({"name": k, "description": v}, context.at(k))) + return result + return [AnthropicToolDefinition.load(item, context.at_index(index)) for index, item in enumerate(data)] @staticmethod def save_tools( @@ -140,28 +149,8 @@ def save_tools( if context is None: context = SaveContext() - if context.collection_format == "array": - return [item.save(context) for item in items] - - # Object format: use name as key - result: dict[str, Any] = {} - for item in items: - item_data = item.save(context) - name = item_data.pop("name", None) - if name: - # Check if we can use shorthand (only primary property set) - if context.use_shorthand and hasattr(item, "_shorthand_property"): - shorthand_prop = item._shorthand_property - if shorthand_prop and len(item_data) == 1 and shorthand_prop in item_data: - result[name] = item_data[shorthand_prop] - continue - result[name] = item_data - else: - # No name, fall back to array format for this item - if "_unnamed" not in result: - result["_unnamed"] = [] - result["_unnamed"].append(item_data) - return result + # The schema declares an ordered collection, so preserve array format + return [item.save(context) for item in items] def save(self, context: SaveContext | None = None) -> dict[str, Any]: """Save the AnthropicMessagesRequest instance to a dictionary. diff --git a/runtime/python/prompty/prompty/model/wire/_AnthropicMessagesResponse.py b/runtime/python/prompty/prompty/model/wire/_AnthropicMessagesResponse.py index 5c621a830..81e41340b 100644 --- a/runtime/python/prompty/prompty/model/wire/_AnthropicMessagesResponse.py +++ b/runtime/python/prompty/prompty/model/wire/_AnthropicMessagesResponse.py @@ -55,11 +55,14 @@ def load(data: Any, context: LoadContext | None = None) -> "AnthropicMessagesRes """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for AnthropicMessagesResponse: {data}") + if "usage" not in data or data["usage"] is None: + raise ValueError(f"{context.at('usage').path}: missing required field") # create new instance instance = AnthropicMessagesResponse() @@ -77,7 +80,7 @@ def load(data: Any, context: LoadContext | None = None) -> "AnthropicMessagesRes if data is not None and "stop_reason" in data: instance.stop_reason = data["stop_reason"] if data is not None and "usage" in data: - instance.usage = AnthropicUsage.load(data["usage"], context) + instance.usage = AnthropicUsage.load(data["usage"], context.at("usage")) if context is not None: instance = context.process_output(instance) return instance diff --git a/runtime/python/prompty/prompty/model/wire/_AnthropicTextBlock.py b/runtime/python/prompty/prompty/model/wire/_AnthropicTextBlock.py index 6b7667ee4..df29256bb 100644 --- a/runtime/python/prompty/prompty/model/wire/_AnthropicTextBlock.py +++ b/runtime/python/prompty/prompty/model/wire/_AnthropicTextBlock.py @@ -39,8 +39,9 @@ def load(data: Any, context: LoadContext | None = None) -> "AnthropicTextBlock": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for AnthropicTextBlock: {data}") diff --git a/runtime/python/prompty/prompty/model/wire/_AnthropicToolDefinition.py b/runtime/python/prompty/prompty/model/wire/_AnthropicToolDefinition.py index c726e5b67..203f8ef94 100644 --- a/runtime/python/prompty/prompty/model/wire/_AnthropicToolDefinition.py +++ b/runtime/python/prompty/prompty/model/wire/_AnthropicToolDefinition.py @@ -44,8 +44,9 @@ def load(data: Any, context: LoadContext | None = None) -> "AnthropicToolDefinit """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for AnthropicToolDefinition: {data}") diff --git a/runtime/python/prompty/prompty/model/wire/_AnthropicToolResultBlock.py b/runtime/python/prompty/prompty/model/wire/_AnthropicToolResultBlock.py index 9144beb12..0e7512fe6 100644 --- a/runtime/python/prompty/prompty/model/wire/_AnthropicToolResultBlock.py +++ b/runtime/python/prompty/prompty/model/wire/_AnthropicToolResultBlock.py @@ -42,8 +42,9 @@ def load(data: Any, context: LoadContext | None = None) -> "AnthropicToolResultB """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for AnthropicToolResultBlock: {data}") diff --git a/runtime/python/prompty/prompty/model/wire/_AnthropicToolUseBlock.py b/runtime/python/prompty/prompty/model/wire/_AnthropicToolUseBlock.py index f4bec46e6..8d498e375 100644 --- a/runtime/python/prompty/prompty/model/wire/_AnthropicToolUseBlock.py +++ b/runtime/python/prompty/prompty/model/wire/_AnthropicToolUseBlock.py @@ -46,8 +46,9 @@ def load(data: Any, context: LoadContext | None = None) -> "AnthropicToolUseBloc """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for AnthropicToolUseBlock: {data}") diff --git a/runtime/python/prompty/prompty/model/wire/_AnthropicUsage.py b/runtime/python/prompty/prompty/model/wire/_AnthropicUsage.py index e85927b6e..9bae64b90 100644 --- a/runtime/python/prompty/prompty/model/wire/_AnthropicUsage.py +++ b/runtime/python/prompty/prompty/model/wire/_AnthropicUsage.py @@ -39,8 +39,9 @@ def load(data: Any, context: LoadContext | None = None) -> "AnthropicUsage": """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for AnthropicUsage: {data}") diff --git a/runtime/python/prompty/prompty/model/wire/_AnthropicWireMessage.py b/runtime/python/prompty/prompty/model/wire/_AnthropicWireMessage.py index cd52a8572..0d92dcd55 100644 --- a/runtime/python/prompty/prompty/model/wire/_AnthropicWireMessage.py +++ b/runtime/python/prompty/prompty/model/wire/_AnthropicWireMessage.py @@ -41,8 +41,9 @@ def load(data: Any, context: LoadContext | None = None) -> "AnthropicWireMessage """ - if context is not None: - data = context.process_input(data) + if context is None: + context = LoadContext() + data = context.process_input(data) if not isinstance(data, dict): raise ValueError(f"Invalid data for AnthropicWireMessage: {data}") diff --git a/runtime/python/prompty/prompty/providers/openai/executor.py b/runtime/python/prompty/prompty/providers/openai/executor.py index 7aa263692..565714d57 100644 --- a/runtime/python/prompty/prompty/providers/openai/executor.py +++ b/runtime/python/prompty/prompty/providers/openai/executor.py @@ -210,29 +210,21 @@ def _property_to_json_schema(prop: Any, *, optional: bool = False, strict: bool if prop.enum_values: schema["enum"] = prop.enum_values - # Array items — default to string if unspecified - if prop.kind == "array": - if hasattr(prop, "items") and prop.items is not None: - schema["items"] = _property_to_json_schema(prop.items, strict=strict) - else: - schema["items"] = {"type": "string"} - - # Object properties (with strict additionalProperties: False) - if prop.kind == "object": - if hasattr(prop, "properties") and prop.properties: - props: dict[str, Any] = {} - required: list[str] = [] - for p in prop.properties: - props[p.name] = _property_to_json_schema( - p, optional=strict and not bool(p.required), strict=strict - ) - if strict or p.required: - required.append(p.name) - schema["properties"] = props - if required: - schema["required"] = required - else: - schema["properties"] = {} + # Array items — bare {"type": "array"} when items is unspecified + if prop.kind == "array" and getattr(prop, "items", None) is not None: + schema["items"] = _property_to_json_schema(prop.items, strict=strict) + + # Object properties — bare {"type": "object"} when properties is empty or absent + if prop.kind == "object" and getattr(prop, "properties", None): + props: dict[str, Any] = {} + required: list[str] = [] + for p in prop.properties: + props[p.name] = _property_to_json_schema(p, optional=strict and not bool(p.required), strict=strict) + if strict or p.required: + required.append(p.name) + schema["properties"] = props + if required: + schema["required"] = required schema["additionalProperties"] = False if prop.kind == "union": @@ -366,9 +358,7 @@ def _responses_tools_to_wire(agent: Prompty) -> list[dict[str, Any]] | None: if tool.description: tool_def["description"] = tool.description if hasattr(tool, "parameters") and tool.parameters: - tool_def["parameters"] = _schema_to_wire( - tool.parameters, strict=bool(getattr(tool, "strict", False)) - ) + tool_def["parameters"] = _schema_to_wire(tool.parameters, strict=bool(getattr(tool, "strict", False))) if hasattr(tool, "strict") and tool.strict: tool_def["strict"] = True if "parameters" in tool_def: diff --git a/runtime/python/prompty/prompty/providers/openai/models.py b/runtime/python/prompty/prompty/providers/openai/models.py index 830b1f2ff..eebd6bf20 100644 --- a/runtime/python/prompty/prompty/providers/openai/models.py +++ b/runtime/python/prompty/prompty/providers/openai/models.py @@ -85,9 +85,15 @@ def _enrich(model_id: str, info: ModelInfo) -> ModelInfo: if info.context_window is None and known.get("context_window") is not None: info.context_window = known["context_window"] - if not info.input_modalities and known.get("input_modalities"): + # Use `is None` rather than truthiness: a known empty list (e.g. embedding + # models have no output modalities) must still be applied, and an empty + # list explicitly supplied by the provider must win over the known value. + # See spec/vectors/discovery/enrichment_vectors.json, + # "openai_enrich_embedding_empty_output_modalities" and + # "openai_enrich_provider_empty_modalities_win". + if info.input_modalities is None and known.get("input_modalities") is not None: info.input_modalities = known["input_modalities"] - if not info.output_modalities and known.get("output_modalities"): + if info.output_modalities is None and known.get("output_modalities") is not None: info.output_modalities = known["output_modalities"] return info diff --git a/runtime/python/prompty/tests/integration/conftest.py b/runtime/python/prompty/tests/integration/conftest.py index 3ce73d33d..e394bd098 100644 --- a/runtime/python/prompty/tests/integration/conftest.py +++ b/runtime/python/prompty/tests/integration/conftest.py @@ -46,7 +46,12 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: _OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "") # optional: proxy via Azure _OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini") # override default chat model _OPENAI_EMBEDDING_MODEL = os.environ.get("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") -_OPENAI_IMAGE_MODEL = os.environ.get("OPENAI_IMAGE_MODEL", "dall-e-2") +# Image generation requires explicit opt-in: the model must be named, because +# available image models vary by account entitlement (dall-e-2 / dall-e-3 are +# retired on current OpenAI accounts and 400 with "does not exist"; newer +# accounts expose gpt-image-1 instead) and image calls cost money. This matches +# the TypeScript suite, which gates on `skipIf(!OPENAI_IMAGE_MODEL)`. +_OPENAI_IMAGE_MODEL = os.environ.get("OPENAI_IMAGE_MODEL", "") _AZURE_KEY = os.environ.get("AZURE_OPENAI_API_KEY", "") _AZURE_ENDPOINT = os.environ.get("AZURE_OPENAI_ENDPOINT", "") _AZURE_CHAT_DEPLOYMENT = os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT", "") @@ -65,8 +70,8 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: skip_openai = pytest.mark.skipif(not has_openai, reason="OPENAI_API_KEY not set") skip_openai_image = pytest.mark.skipif( - not has_openai, - reason="OPENAI_API_KEY not set", + not (has_openai and _OPENAI_IMAGE_MODEL), + reason="OPENAI_API_KEY and OPENAI_IMAGE_MODEL must both be set (image models are account-specific and billable)", ) skip_foundry = pytest.mark.skipif(not has_foundry, reason="Azure OpenAI env vars not set") skip_azure = skip_foundry # backward-compat alias diff --git a/runtime/python/prompty/tests/model/agent/test_prompty.py b/runtime/python/prompty/tests/model/agent/test_prompty.py index 056dc288b..e0cec6df5 100644 --- a/runtime/python/prompty/tests/model/agent/test_prompty.py +++ b/runtime/python/prompty/tests/model/agent/test_prompty.py @@ -82,17 +82,7 @@ def test_load_json_prompty(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -142,26 +132,7 @@ def test_load_yaml_prompty(): template: format: mustache parser: prompty - instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" + instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -172,17 +143,7 @@ def test_load_yaml_prompty(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -265,17 +226,7 @@ def test_roundtrip_json_prompty(): assert reloaded.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( reloaded.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -508,17 +459,7 @@ def test_load_json_prompty_1(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -568,26 +509,7 @@ def test_load_yaml_prompty_1(): template: format: mustache parser: prompty - instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" + instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -598,17 +520,7 @@ def test_load_yaml_prompty_1(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -690,17 +602,7 @@ def test_roundtrip_json_prompty_1(): assert reloaded.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( reloaded.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -933,17 +835,7 @@ def test_load_json_prompty_2(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -993,26 +885,7 @@ def test_load_yaml_prompty_2(): template: format: mustache parser: prompty - instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" + instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -1023,17 +896,7 @@ def test_load_yaml_prompty_2(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -1117,17 +980,7 @@ def test_roundtrip_json_prompty_2(): assert reloaded.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( reloaded.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -1363,17 +1216,7 @@ def test_load_json_prompty_3(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -1423,26 +1266,7 @@ def test_load_yaml_prompty_3(): template: format: mustache parser: prompty - instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" + instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -1453,17 +1277,7 @@ def test_load_yaml_prompty_3(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -1546,17 +1360,7 @@ def test_roundtrip_json_prompty_3(): assert reloaded.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( reloaded.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -1793,17 +1597,7 @@ def test_load_json_prompty_4(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -1853,26 +1647,7 @@ def test_load_yaml_prompty_4(): template: format: mustache parser: prompty - instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" + instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -1883,17 +1658,7 @@ def test_load_yaml_prompty_4(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -1979,17 +1744,7 @@ def test_roundtrip_json_prompty_4(): assert reloaded.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( reloaded.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -2231,17 +1986,7 @@ def test_load_json_prompty_5(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -2291,26 +2036,7 @@ def test_load_yaml_prompty_5(): template: format: mustache parser: prompty - instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" + instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -2321,17 +2047,7 @@ def test_load_yaml_prompty_5(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -2416,17 +2132,7 @@ def test_roundtrip_json_prompty_5(): assert reloaded.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( reloaded.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -2668,17 +2374,7 @@ def test_load_json_prompty_6(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -2728,26 +2424,7 @@ def test_load_yaml_prompty_6(): template: format: mustache parser: prompty - instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" + instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -2758,17 +2435,7 @@ def test_load_yaml_prompty_6(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -2855,17 +2522,7 @@ def test_roundtrip_json_prompty_6(): assert reloaded.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( reloaded.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -3110,17 +2767,7 @@ def test_load_json_prompty_7(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -3170,26 +2817,7 @@ def test_load_yaml_prompty_7(): template: format: mustache parser: prompty - instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" + instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -3200,17 +2828,7 @@ def test_load_yaml_prompty_7(): assert instance.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( instance.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) @@ -3296,17 +2914,7 @@ def test_roundtrip_json_prompty_7(): assert reloaded.description == "A basic prompt that uses the GPT-3 chat API to answer questions" assert ( reloaded.instructions - == """system: -You are an AI assistant who helps people find information. -As the assistant, you answer questions briefly, succinctly, -and in a personable manner using markdown and even add some -personal flair with appropriate emojis. - -# Customer -You are helping {{firstName}} {{lastName}} to find answers to -their questions. Use their name to address them in your responses. -user: -{{question}}""" + == "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" ) diff --git a/runtime/python/prompty/tests/model/events/test_session_trace.py b/runtime/python/prompty/tests/model/events/test_session_trace.py index 97747afad..32bb02bc2 100644 --- a/runtime/python/prompty/tests/model/events/test_session_trace.py +++ b/runtime/python/prompty/tests/model/events/test_session_trace.py @@ -12,7 +12,18 @@ def test_load_json_sessiontrace(): "version": "1", "runtime": "typescript", "promptyVersion": "2.0.0", - "sessionId": "sess_abc123" + "sessionId": "sess_abc123", + "events": [ + { + "id": "evt_abc123", + "type": "session_start", + "timestamp": "2026-06-09T20:00:00Z", + "sessionId": "sess_abc123", + "turnId": "turn_001", + "parentId": "evt_parent", + "spanId": "span_hook_001" + } + ] } """ data = json.loads(json_data, strict=False) @@ -30,6 +41,14 @@ def test_load_yaml_sessiontrace(): runtime: typescript promptyVersion: 2.0.0 sessionId: sess_abc123 + events: + - id: evt_abc123 + type: session_start + timestamp: "2026-06-09T20:00:00Z" + sessionId: sess_abc123 + turnId: turn_001 + parentId: evt_parent + spanId: span_hook_001 """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -48,7 +67,18 @@ def test_roundtrip_json_sessiontrace(): "version": "1", "runtime": "typescript", "promptyVersion": "2.0.0", - "sessionId": "sess_abc123" + "sessionId": "sess_abc123", + "events": [ + { + "id": "evt_abc123", + "type": "session_start", + "timestamp": "2026-06-09T20:00:00Z", + "sessionId": "sess_abc123", + "turnId": "turn_001", + "parentId": "evt_parent", + "spanId": "span_hook_001" + } + ] } """ original_data = json.loads(json_data, strict=False) @@ -69,7 +99,18 @@ def test_to_json_sessiontrace(): "version": "1", "runtime": "typescript", "promptyVersion": "2.0.0", - "sessionId": "sess_abc123" + "sessionId": "sess_abc123", + "events": [ + { + "id": "evt_abc123", + "type": "session_start", + "timestamp": "2026-06-09T20:00:00Z", + "sessionId": "sess_abc123", + "turnId": "turn_001", + "parentId": "evt_parent", + "spanId": "span_hook_001" + } + ] } """ data = json.loads(json_data, strict=False) @@ -87,7 +128,18 @@ def test_to_yaml_sessiontrace(): "version": "1", "runtime": "typescript", "promptyVersion": "2.0.0", - "sessionId": "sess_abc123" + "sessionId": "sess_abc123", + "events": [ + { + "id": "evt_abc123", + "type": "session_start", + "timestamp": "2026-06-09T20:00:00Z", + "sessionId": "sess_abc123", + "turnId": "turn_001", + "parentId": "evt_parent", + "spanId": "span_hook_001" + } + ] } """ data = json.loads(json_data, strict=False) diff --git a/runtime/python/prompty/tests/model/events/test_turn_trace.py b/runtime/python/prompty/tests/model/events/test_turn_trace.py index 6328d732b..7bf7f2961 100644 --- a/runtime/python/prompty/tests/model/events/test_turn_trace.py +++ b/runtime/python/prompty/tests/model/events/test_turn_trace.py @@ -11,7 +11,18 @@ def test_load_json_turntrace(): { "version": "1", "runtime": "typescript", - "promptyVersion": "2.0.0" + "promptyVersion": "2.0.0", + "events": [ + { + "id": "evt_abc123", + "type": "turn_start", + "timestamp": "2026-06-09T20:00:00Z", + "turnId": "turn_001", + "iteration": 0, + "parentId": "evt_parent", + "spanId": "span_tool_001" + } + ] } """ data = json.loads(json_data, strict=False) @@ -27,6 +38,14 @@ def test_load_yaml_turntrace(): version: "1" runtime: typescript promptyVersion: 2.0.0 + events: + - id: evt_abc123 + type: turn_start + timestamp: "2026-06-09T20:00:00Z" + turnId: turn_001 + iteration: 0 + parentId: evt_parent + spanId: span_tool_001 """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -43,7 +62,18 @@ def test_roundtrip_json_turntrace(): { "version": "1", "runtime": "typescript", - "promptyVersion": "2.0.0" + "promptyVersion": "2.0.0", + "events": [ + { + "id": "evt_abc123", + "type": "turn_start", + "timestamp": "2026-06-09T20:00:00Z", + "turnId": "turn_001", + "iteration": 0, + "parentId": "evt_parent", + "spanId": "span_tool_001" + } + ] } """ original_data = json.loads(json_data, strict=False) @@ -62,7 +92,18 @@ def test_to_json_turntrace(): { "version": "1", "runtime": "typescript", - "promptyVersion": "2.0.0" + "promptyVersion": "2.0.0", + "events": [ + { + "id": "evt_abc123", + "type": "turn_start", + "timestamp": "2026-06-09T20:00:00Z", + "turnId": "turn_001", + "iteration": 0, + "parentId": "evt_parent", + "spanId": "span_tool_001" + } + ] } """ data = json.loads(json_data, strict=False) @@ -79,7 +120,18 @@ def test_to_yaml_turntrace(): { "version": "1", "runtime": "typescript", - "promptyVersion": "2.0.0" + "promptyVersion": "2.0.0", + "events": [ + { + "id": "evt_abc123", + "type": "turn_start", + "timestamp": "2026-06-09T20:00:00Z", + "turnId": "turn_001", + "iteration": 0, + "parentId": "evt_parent", + "spanId": "span_tool_001" + } + ] } """ data = json.loads(json_data, strict=False) diff --git a/runtime/python/prompty/tests/model/pipeline/test_engine_checkpoint.py b/runtime/python/prompty/tests/model/pipeline/test_engine_checkpoint.py index 8a4abe5d4..c35a4746a 100644 --- a/runtime/python/prompty/tests/model/pipeline/test_engine_checkpoint.py +++ b/runtime/python/prompty/tests/model/pipeline/test_engine_checkpoint.py @@ -12,7 +12,8 @@ def test_load_json_enginecheckpoint(): "id": "ckpt_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "runId": "run_abc123" + "runId": "run_abc123", + "contextState": {} } """ data = json.loads(json_data, strict=False) @@ -30,6 +31,7 @@ def test_load_yaml_enginecheckpoint(): sessionId: sess_abc123 turnId: turn_abc123 runId: run_abc123 + contextState: {} """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -48,7 +50,8 @@ def test_roundtrip_json_enginecheckpoint(): "id": "ckpt_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "runId": "run_abc123" + "runId": "run_abc123", + "contextState": {} } """ original_data = json.loads(json_data, strict=False) @@ -69,7 +72,8 @@ def test_to_json_enginecheckpoint(): "id": "ckpt_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "runId": "run_abc123" + "runId": "run_abc123", + "contextState": {} } """ data = json.loads(json_data, strict=False) @@ -87,7 +91,8 @@ def test_to_yaml_enginecheckpoint(): "id": "ckpt_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "runId": "run_abc123" + "runId": "run_abc123", + "contextState": {} } """ data = json.loads(json_data, strict=False) diff --git a/runtime/python/prompty/tests/model/pipeline/test_model_invocation_context_snapshot.py b/runtime/python/prompty/tests/model/pipeline/test_model_invocation_context_snapshot.py index 5172d39d7..e4a7ff3bc 100644 --- a/runtime/python/prompty/tests/model/pipeline/test_model_invocation_context_snapshot.py +++ b/runtime/python/prompty/tests/model/pipeline/test_model_invocation_context_snapshot.py @@ -12,7 +12,8 @@ def test_load_json_modelinvocationcontextsnapshot(): "id": "context:inv_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "invocationId": "inv_abc123" + "invocationId": "inv_abc123", + "contextState": {} } """ data = json.loads(json_data, strict=False) @@ -30,6 +31,7 @@ def test_load_yaml_modelinvocationcontextsnapshot(): sessionId: sess_abc123 turnId: turn_abc123 invocationId: inv_abc123 + contextState: {} """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -48,7 +50,8 @@ def test_roundtrip_json_modelinvocationcontextsnapshot(): "id": "context:inv_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "invocationId": "inv_abc123" + "invocationId": "inv_abc123", + "contextState": {} } """ original_data = json.loads(json_data, strict=False) @@ -69,7 +72,8 @@ def test_to_json_modelinvocationcontextsnapshot(): "id": "context:inv_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "invocationId": "inv_abc123" + "invocationId": "inv_abc123", + "contextState": {} } """ data = json.loads(json_data, strict=False) @@ -87,7 +91,8 @@ def test_to_yaml_modelinvocationcontextsnapshot(): "id": "context:inv_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "invocationId": "inv_abc123" + "invocationId": "inv_abc123", + "contextState": {} } """ data = json.loads(json_data, strict=False) diff --git a/runtime/python/prompty/tests/model/pipeline/test_model_reconciliation_state.py b/runtime/python/prompty/tests/model/pipeline/test_model_reconciliation_state.py index 1a3f0c5d8..52d89f7b5 100644 --- a/runtime/python/prompty/tests/model/pipeline/test_model_reconciliation_state.py +++ b/runtime/python/prompty/tests/model/pipeline/test_model_reconciliation_state.py @@ -10,7 +10,17 @@ def test_load_json_modelreconciliationstate(): json_data = r""" { "invocationId": "inv_abc123", - "message": "provider connection dropped after request was sent" + "message": "provider connection dropped after request was sent", + "request": { + "context": { + "id": "context:inv_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "invocationId": "inv_abc123", + "iteration": 1, + "contextState": {} + } + } } """ data = json.loads(json_data, strict=False) @@ -24,6 +34,14 @@ def test_load_yaml_modelreconciliationstate(): yaml_data = r""" invocationId: inv_abc123 message: provider connection dropped after request was sent + request: + context: + id: "context:inv_abc123" + sessionId: sess_abc123 + turnId: turn_abc123 + invocationId: inv_abc123 + iteration: 1 + contextState: {} """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -38,7 +56,17 @@ def test_roundtrip_json_modelreconciliationstate(): json_data = r""" { "invocationId": "inv_abc123", - "message": "provider connection dropped after request was sent" + "message": "provider connection dropped after request was sent", + "request": { + "context": { + "id": "context:inv_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "invocationId": "inv_abc123", + "iteration": 1, + "contextState": {} + } + } } """ original_data = json.loads(json_data, strict=False) @@ -55,7 +83,17 @@ def test_to_json_modelreconciliationstate(): json_data = r""" { "invocationId": "inv_abc123", - "message": "provider connection dropped after request was sent" + "message": "provider connection dropped after request was sent", + "request": { + "context": { + "id": "context:inv_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "invocationId": "inv_abc123", + "iteration": 1, + "contextState": {} + } + } } """ data = json.loads(json_data, strict=False) @@ -71,7 +109,17 @@ def test_to_yaml_modelreconciliationstate(): json_data = r""" { "invocationId": "inv_abc123", - "message": "provider connection dropped after request was sent" + "message": "provider connection dropped after request was sent", + "request": { + "context": { + "id": "context:inv_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "invocationId": "inv_abc123", + "iteration": 1, + "contextState": {} + } + } } """ data = json.loads(json_data, strict=False) diff --git a/runtime/python/prompty/tests/model/pipeline/test_resume_context.py b/runtime/python/prompty/tests/model/pipeline/test_resume_context.py index 6ff33be0a..47901efab 100644 --- a/runtime/python/prompty/tests/model/pipeline/test_resume_context.py +++ b/runtime/python/prompty/tests/model/pipeline/test_resume_context.py @@ -9,7 +9,16 @@ def test_load_json_resumecontext(): json_data = r""" { - "lastJournalSequence": 12 + "lastJournalSequence": 12, + "checkpoint": { + "id": "ckpt_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "runId": "run_abc123", + "iteration": 1, + "lastSequence": 1, + "contextState": {} + } } """ data = json.loads(json_data, strict=False) @@ -21,6 +30,14 @@ def test_load_json_resumecontext(): def test_load_yaml_resumecontext(): yaml_data = r""" lastJournalSequence: 12 + checkpoint: + id: ckpt_abc123 + sessionId: sess_abc123 + turnId: turn_abc123 + runId: run_abc123 + iteration: 1 + lastSequence: 1 + contextState: {} """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -33,7 +50,16 @@ def test_roundtrip_json_resumecontext(): """Test that load -> save -> load produces equivalent data.""" json_data = r""" { - "lastJournalSequence": 12 + "lastJournalSequence": 12, + "checkpoint": { + "id": "ckpt_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "runId": "run_abc123", + "iteration": 1, + "lastSequence": 1, + "contextState": {} + } } """ original_data = json.loads(json_data, strict=False) @@ -48,7 +74,16 @@ def test_to_json_resumecontext(): """Test that to_json produces valid JSON.""" json_data = r""" { - "lastJournalSequence": 12 + "lastJournalSequence": 12, + "checkpoint": { + "id": "ckpt_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "runId": "run_abc123", + "iteration": 1, + "lastSequence": 1, + "contextState": {} + } } """ data = json.loads(json_data, strict=False) @@ -63,7 +98,16 @@ def test_to_yaml_resumecontext(): """Test that to_yaml produces valid YAML.""" json_data = r""" { - "lastJournalSequence": 12 + "lastJournalSequence": 12, + "checkpoint": { + "id": "ckpt_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "runId": "run_abc123", + "iteration": 1, + "lastSequence": 1, + "contextState": {} + } } """ data = json.loads(json_data, strict=False) diff --git a/runtime/python/prompty/tests/model/pipeline/test_turn_commit.py b/runtime/python/prompty/tests/model/pipeline/test_turn_commit.py index 8956b26e2..1901d0e03 100644 --- a/runtime/python/prompty/tests/model/pipeline/test_turn_commit.py +++ b/runtime/python/prompty/tests/model/pipeline/test_turn_commit.py @@ -10,7 +10,8 @@ def test_load_json_turncommit(): json_data = r""" { "sessionId": "sess_abc123", - "turnId": "turn_abc123" + "turnId": "turn_abc123", + "contextState": {} } """ data = json.loads(json_data, strict=False) @@ -24,6 +25,7 @@ def test_load_yaml_turncommit(): yaml_data = r""" sessionId: sess_abc123 turnId: turn_abc123 + contextState: {} """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -38,7 +40,8 @@ def test_roundtrip_json_turncommit(): json_data = r""" { "sessionId": "sess_abc123", - "turnId": "turn_abc123" + "turnId": "turn_abc123", + "contextState": {} } """ original_data = json.loads(json_data, strict=False) @@ -55,7 +58,8 @@ def test_to_json_turncommit(): json_data = r""" { "sessionId": "sess_abc123", - "turnId": "turn_abc123" + "turnId": "turn_abc123", + "contextState": {} } """ data = json.loads(json_data, strict=False) @@ -71,7 +75,8 @@ def test_to_yaml_turncommit(): json_data = r""" { "sessionId": "sess_abc123", - "turnId": "turn_abc123" + "turnId": "turn_abc123", + "contextState": {} } """ data = json.loads(json_data, strict=False) diff --git a/runtime/python/prompty/tests/model/tools/test_tool_context.py b/runtime/python/prompty/tests/model/tools/test_tool_context.py index 20818f4b8..01d818416 100644 --- a/runtime/python/prompty/tests/model/tools/test_tool_context.py +++ b/runtime/python/prompty/tests/model/tools/test_tool_context.py @@ -11,7 +11,21 @@ def test_load_json_toolcontext(): { "metadata": { "userId": "user-123" - } + }, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "value": "Hello!" + } + ], + "metadata": { + "source": "user-input" + } + } + ] } """ data = json.loads(json_data, strict=False) @@ -23,6 +37,13 @@ def test_load_yaml_toolcontext(): yaml_data = r""" metadata: userId: user-123 + messages: + - role: user + parts: + - kind: text + value: Hello! + metadata: + source: user-input """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -36,7 +57,21 @@ def test_roundtrip_json_toolcontext(): { "metadata": { "userId": "user-123" - } + }, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "value": "Hello!" + } + ], + "metadata": { + "source": "user-input" + } + } + ] } """ original_data = json.loads(json_data, strict=False) @@ -52,7 +87,21 @@ def test_to_json_toolcontext(): { "metadata": { "userId": "user-123" - } + }, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "value": "Hello!" + } + ], + "metadata": { + "source": "user-input" + } + } + ] } """ data = json.loads(json_data, strict=False) @@ -69,7 +118,21 @@ def test_to_yaml_toolcontext(): { "metadata": { "userId": "user-123" - } + }, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "value": "Hello!" + } + ], + "metadata": { + "source": "user-input" + } + } + ] } """ data = json.loads(json_data, strict=False) diff --git a/runtime/python/prompty/tests/model/tracing/test_trace_file.py b/runtime/python/prompty/tests/model/tracing/test_trace_file.py index 694a524b7..0223c34b2 100644 --- a/runtime/python/prompty/tests/model/tracing/test_trace_file.py +++ b/runtime/python/prompty/tests/model/tracing/test_trace_file.py @@ -10,7 +10,17 @@ def test_load_json_tracefile(): json_data = r""" { "runtime": "python", - "version": "2.0.0" + "version": "2.0.0", + "trace": { + "name": "prompty.core.pipeline.run", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + }, + "signature": "prompty.core.pipeline.run", + "error": "Connection refused" + } } """ data = json.loads(json_data, strict=False) @@ -24,6 +34,14 @@ def test_load_yaml_tracefile(): yaml_data = r""" runtime: python version: 2.0.0 + trace: + name: prompty.core.pipeline.run + __time: + start: "2026-04-04T12:00:00Z" + end: "2026-04-04T12:00:01Z" + duration: 1000 + signature: prompty.core.pipeline.run + error: Connection refused """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -38,7 +56,17 @@ def test_roundtrip_json_tracefile(): json_data = r""" { "runtime": "python", - "version": "2.0.0" + "version": "2.0.0", + "trace": { + "name": "prompty.core.pipeline.run", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + }, + "signature": "prompty.core.pipeline.run", + "error": "Connection refused" + } } """ original_data = json.loads(json_data, strict=False) @@ -55,7 +83,17 @@ def test_to_json_tracefile(): json_data = r""" { "runtime": "python", - "version": "2.0.0" + "version": "2.0.0", + "trace": { + "name": "prompty.core.pipeline.run", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + }, + "signature": "prompty.core.pipeline.run", + "error": "Connection refused" + } } """ data = json.loads(json_data, strict=False) @@ -71,7 +109,17 @@ def test_to_yaml_tracefile(): json_data = r""" { "runtime": "python", - "version": "2.0.0" + "version": "2.0.0", + "trace": { + "name": "prompty.core.pipeline.run", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + }, + "signature": "prompty.core.pipeline.run", + "error": "Connection refused" + } } """ data = json.loads(json_data, strict=False) diff --git a/runtime/python/prompty/tests/model/tracing/test_trace_span.py b/runtime/python/prompty/tests/model/tracing/test_trace_span.py index 0a6835804..0d482ab56 100644 --- a/runtime/python/prompty/tests/model/tracing/test_trace_span.py +++ b/runtime/python/prompty/tests/model/tracing/test_trace_span.py @@ -11,7 +11,12 @@ def test_load_json_tracespan(): { "name": "prompty.core.pipeline.run", "signature": "prompty.core.pipeline.run", - "error": "Connection refused" + "error": "Connection refused", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + } } """ data = json.loads(json_data, strict=False) @@ -27,6 +32,10 @@ def test_load_yaml_tracespan(): name: prompty.core.pipeline.run signature: prompty.core.pipeline.run error: Connection refused + __time: + start: "2026-04-04T12:00:00Z" + end: "2026-04-04T12:00:01Z" + duration: 1000 """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -43,7 +52,12 @@ def test_roundtrip_json_tracespan(): { "name": "prompty.core.pipeline.run", "signature": "prompty.core.pipeline.run", - "error": "Connection refused" + "error": "Connection refused", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + } } """ original_data = json.loads(json_data, strict=False) @@ -62,7 +76,12 @@ def test_to_json_tracespan(): { "name": "prompty.core.pipeline.run", "signature": "prompty.core.pipeline.run", - "error": "Connection refused" + "error": "Connection refused", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + } } """ data = json.loads(json_data, strict=False) @@ -79,7 +98,12 @@ def test_to_yaml_tracespan(): { "name": "prompty.core.pipeline.run", "signature": "prompty.core.pipeline.run", - "error": "Connection refused" + "error": "Connection refused", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + } } """ data = json.loads(json_data, strict=False) diff --git a/runtime/python/prompty/tests/model/wire/test_anthropic_messages_request.py b/runtime/python/prompty/tests/model/wire/test_anthropic_messages_request.py index b97daf3ec..02b39d199 100644 --- a/runtime/python/prompty/tests/model/wire/test_anthropic_messages_request.py +++ b/runtime/python/prompty/tests/model/wire/test_anthropic_messages_request.py @@ -17,6 +17,12 @@ def test_load_json_anthropicmessagesrequest(): "top_k": 40, "stop_sequences": [ "\n\nHuman:" + ], + "messages": [ + { + "role": "user", + "content": [] + } ] } """ @@ -41,6 +47,9 @@ def test_load_yaml_anthropicmessagesrequest(): top_k: 40 stop_sequences: - "\n\nHuman:" + messages: + - role: user + content: [] """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -66,6 +75,12 @@ def test_roundtrip_json_anthropicmessagesrequest(): "top_k": 40, "stop_sequences": [ "\n\nHuman:" + ], + "messages": [ + { + "role": "user", + "content": [] + } ] } """ @@ -94,6 +109,12 @@ def test_to_json_anthropicmessagesrequest(): "top_k": 40, "stop_sequences": [ "\n\nHuman:" + ], + "messages": [ + { + "role": "user", + "content": [] + } ] } """ @@ -117,6 +138,12 @@ def test_to_yaml_anthropicmessagesrequest(): "top_k": 40, "stop_sequences": [ "\n\nHuman:" + ], + "messages": [ + { + "role": "user", + "content": [] + } ] } """ diff --git a/runtime/python/prompty/tests/model/wire/test_anthropic_messages_response.py b/runtime/python/prompty/tests/model/wire/test_anthropic_messages_response.py index 4ace73d0a..a5fd37924 100644 --- a/runtime/python/prompty/tests/model/wire/test_anthropic_messages_response.py +++ b/runtime/python/prompty/tests/model/wire/test_anthropic_messages_response.py @@ -11,7 +11,11 @@ def test_load_json_anthropicmessagesresponse(): { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn" + "stop_reason": "end_turn", + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } } """ data = json.loads(json_data, strict=False) @@ -27,6 +31,9 @@ def test_load_yaml_anthropicmessagesresponse(): id: msg_01XFDUDYJgAACzvnptvVoYEL model: claude-sonnet-4-20250514 stop_reason: end_turn + usage: + input_tokens: 150 + output_tokens: 42 """ data = yaml.load(yaml_data, Loader=yaml.FullLoader) @@ -43,7 +50,11 @@ def test_roundtrip_json_anthropicmessagesresponse(): { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn" + "stop_reason": "end_turn", + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } } """ original_data = json.loads(json_data, strict=False) @@ -62,7 +73,11 @@ def test_to_json_anthropicmessagesresponse(): { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn" + "stop_reason": "end_turn", + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } } """ data = json.loads(json_data, strict=False) @@ -79,7 +94,11 @@ def test_to_yaml_anthropicmessagesresponse(): { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn" + "stop_reason": "end_turn", + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } } """ data = json.loads(json_data, strict=False) diff --git a/runtime/python/prompty/tests/test_connection_roundtrip_vectors.py b/runtime/python/prompty/tests/test_connection_roundtrip_vectors.py new file mode 100644 index 000000000..afafde526 --- /dev/null +++ b/runtime/python/prompty/tests/test_connection_roundtrip_vectors.py @@ -0,0 +1,41 @@ +"""Validate the canonical forward-compatible Connection roundtrip contract.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from prompty.model import Connection, ReferenceConnection + +_VECTORS_PATH = Path(__file__).resolve().parents[4] / "spec" / "vectors" / "model" / "connection_roundtrip_vectors.json" + + +def _load_vectors() -> list[dict[str, Any]]: + document: dict[str, Any] = json.loads(_VECTORS_PATH.read_text(encoding="utf-8")) + return document["vectors"] + + +@pytest.mark.parametrize("vector", _load_vectors(), ids=lambda vector: vector["name"]) +def test_connection_roundtrip_vectors_preserve_exact_discriminator_and_payload(vector: dict[str, Any]) -> None: + """Preserve known and unknown Connection values through load, save, and reload.""" + + assert vector["operation"] == "load-save-reload" + expected = vector["expected"] + + loaded = Connection.load(vector["input"]) + if expected["kind"] == "reference": + assert isinstance(loaded, ReferenceConnection), vector["name"] + else: + assert not isinstance(loaded, ReferenceConnection), vector["name"] + + saved = loaded.save() + assert saved["kind"] == expected["kind"], vector["name"] + assert saved == expected, vector["name"] + + reloaded = Connection.load(saved) + resaved = reloaded.save() + assert resaved["kind"] == expected["kind"], vector["name"] + assert resaved == expected, vector["name"] diff --git a/runtime/python/prompty/tests/test_content_part_discriminator_vectors.py b/runtime/python/prompty/tests/test_content_part_discriminator_vectors.py new file mode 100644 index 000000000..8c3377518 --- /dev/null +++ b/runtime/python/prompty/tests/test_content_part_discriminator_vectors.py @@ -0,0 +1,38 @@ +"""Validate the canonical closed ContentPart discriminator contract.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from prompty.model import ContentPart, TextPart + +_VECTORS_PATH = ( + Path(__file__).resolve().parents[4] / "spec" / "vectors" / "model" / "content_part_discriminator_vectors.json" +) + + +def _load_vectors() -> list[dict[str, Any]]: + document: dict[str, Any] = json.loads(_VECTORS_PATH.read_text(encoding="utf-8")) + return document["vectors"] + + +@pytest.mark.parametrize("vector", _load_vectors(), ids=lambda vector: vector["name"]) +def test_content_part_discriminator_vectors_enforce_closed_case_sensitive_kinds(vector: dict[str, Any]) -> None: + """Load known kinds and reject unknown or case-colliding discriminator values.""" + + if vector["operation"] == "load": + loaded = ContentPart.load(vector["input"]) + assert isinstance(loaded, TextPart), vector["name"] + assert loaded.save() == vector["expected"], vector["name"] + return + + with pytest.raises(ValueError) as error: + ContentPart.load(vector["input"]) + + diagnostic = str(error.value) + assert vector["expected"]["discriminator"] in diagnostic, vector["name"] + assert vector["expected"]["value"] in diagnostic, vector["name"] diff --git a/runtime/python/prompty/tests/test_loader.py b/runtime/python/prompty/tests/test_loader.py index 0fca08d92..0513aac14 100644 --- a/runtime/python/prompty/tests/test_loader.py +++ b/runtime/python/prompty/tests/test_loader.py @@ -64,7 +64,9 @@ def test_load_no_model(self): """A prompt with no model specified still loads.""" agent = load(PROMPTS / "no_model.prompty") assert agent.name == "no-model" - assert agent.model is not None # model provides a default + # spec/vectors/load/load_vectors.json [empty_frontmatter_body_only] + # expects "model": null when frontmatter declares no model. + assert agent.model is None # --------------------------------------------------------------------------- @@ -479,9 +481,10 @@ def test_name_only(self): """Frontmatter with only a name — no model, no schema.""" agent = load(PROMPTS / "shorthand_name_only.prompty") assert agent.name == "just-a-name" - assert agent.model is not None # Prompty.load() provides default Model - assert agent.model.id == "" - assert len(agent.inputs) == 0 + # spec/vectors/load/load_vectors.json [empty_frontmatter_body_only] + # expects "model": null when frontmatter declares no model. + assert agent.model is None + assert agent.inputs is None assert agent.instructions is not None assert "helpful assistant" in agent.instructions diff --git a/runtime/python/prompty/tests/test_models.py b/runtime/python/prompty/tests/test_models.py index fc3455a7d..e70ae2565 100644 --- a/runtime/python/prompty/tests/test_models.py +++ b/runtime/python/prompty/tests/test_models.py @@ -96,8 +96,11 @@ def test_unknown_model_not_enriched(self) -> None: info = ModelInfo(id="ft:gpt-4o:my-org:custom") enriched = _enrich("ft:gpt-4o:my-org:custom", info) assert enriched.context_window is None - assert enriched.input_modalities == [] - assert enriched.output_modalities == [] + # Vector "openai_enrich_unknown_id_is_noop" in + # spec/vectors/discovery/enrichment_vectors.json expects the output to + # carry only `id` -- the modality keys are absent, not empty lists. + assert enriched.input_modalities is None + assert enriched.output_modalities is None def test_does_not_overwrite_existing_values(self) -> None: info = ModelInfo(id="gpt-4o", context_window=999, input_modalities=["audio"]) @@ -207,11 +210,15 @@ def test_missing_context_length(self) -> None: info = foundry_map_model(m) assert info.context_window is None - def test_modalities_are_empty(self) -> None: + def test_modalities_are_absent(self) -> None: m = _fake_model("gpt-4o", "azure", max_context_length=128_000) info = foundry_map_model(m) - assert info.input_modalities == [] - assert info.output_modalities == [] + # Vector "foundry_deployment_flat_v1" in + # spec/vectors/discovery/discovery_vectors.json supplies capabilities + # without modality keys and expects no inputModalities / + # outputModalities in the output -- absent, not empty lists. + assert info.input_modalities is None + assert info.output_modalities is None class TestFoundryListModels: diff --git a/runtime/python/prompty/tests/test_named_collection_vectors.py b/runtime/python/prompty/tests/test_named_collection_vectors.py new file mode 100644 index 000000000..d0356a4d2 --- /dev/null +++ b/runtime/python/prompty/tests/test_named_collection_vectors.py @@ -0,0 +1,134 @@ +"""Execute the shared named-collection vectors against the emitted Python models. + +Ported from ``runtime/rust/prompty/tests/named_collection_vectors.rs`` so the +contract is exercised in Python as well. Rust was its only executable home for +most of this effort; Go, TypeScript and C# ports landed alongside this one. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from prompty.model import Prompty + +_VECTORS_PATH = Path(__file__).resolve().parents[4] / "spec" / "vectors" / "model" / "named_collection_vectors.json" + + +def _vectors() -> list[dict[str, Any]]: + document: dict[str, Any] = json.loads(_VECTORS_PATH.read_text(encoding="utf-8")) + return document["vectors"] + + +def _vectors_for(operation: str) -> list[dict[str, Any]]: + return [vector for vector in _vectors() if vector["operation"] == operation] + + +def _semantic_entries(collection: Any) -> list[dict[str, Any]]: + """Normalize either named-collection wire form into comparable entries. + + Array form supplies ``name`` already (defaulted to empty when absent); + object form carries it as the key. + """ + if isinstance(collection, list): + entries = [] + for entry in collection: + assert isinstance(entry, dict), "array-form named collection entries must be objects" + normalized = dict(entry) + normalized.setdefault("name", "") + entries.append(normalized) + return entries + if isinstance(collection, dict): + entries = [] + for name, entry in collection.items(): + assert isinstance(entry, dict), "object-form named collection entries must be objects" + normalized = dict(entry) + normalized["name"] = name + entries.append(normalized) + return entries + raise AssertionError(f"named collection must be a list or dict, got {collection!r}") + + +def _assert_subset(actual: Any, expected: Any, path: str) -> None: + if isinstance(expected, dict): + assert isinstance(actual, dict), f"[{path}] expected object, got {actual!r}" + for key, expected_value in expected.items(): + assert key in actual, f"[{path}] missing expected key {key!r}" + _assert_subset(actual[key], expected_value, f"{path}.{key}") + elif isinstance(expected, list): + assert isinstance(actual, list), f"[{path}] expected array, got {actual!r}" + assert len(actual) == len(expected), f"[{path}] array length changed" + for index, expected_value in enumerate(expected): + _assert_subset(actual[index], expected_value, f"{path}[{index}]") + else: + assert actual == expected, f"[{path}] value changed: expected {expected!r}, got {actual!r}" + + +def _assert_collection(vector_name: str, collection: Any, expected: dict[str, Any]) -> None: + if isinstance(collection, list): + actual_format = "array" + elif isinstance(collection, dict): + actual_format = "object" + else: + actual_format = "invalid" + assert actual_format == expected["collectionFormat"], f"[{vector_name}] canonical collection format changed" + + # wireEntries assert that the raw saved entry at an index never materializes + # the listed fields -- they are not entry subsets. + for assertion in expected.get("wireEntries", []): + assert isinstance(collection, list), f"[{vector_name}] wire entry assertions require array form" + index = assertion["index"] + assert index < len(collection), f"[{vector_name}] missing wire entry at index {index}" + entry = collection[index] + for field in assertion["absentFields"]: + assert field not in entry, f"[{vector_name}] wire entry {index} unexpectedly serialized field {field!r}" + + actual_entries = _semantic_entries(collection) + expected_entries = expected["entries"] + assert len(actual_entries) == len(expected_entries), f"[{vector_name}] named collection entry count changed" + + # absentEntryFields applies to every entry, not only the matching one. + for entry in actual_entries: + for field in expected.get("absentEntryFields", []): + assert entry.get(field) is None, ( + f"[{vector_name}] entry {entry.get('name')!r} unexpectedly populated field {field!r}" + ) + + if expected.get("preserveOrder") is True: + for index, expected_entry in enumerate(expected_entries): + _assert_subset(actual_entries[index], expected_entry, f"{vector_name}.entries[{index}]") + else: + actual_by_name = {entry["name"]: entry for entry in actual_entries} + for expected_entry in expected_entries: + name = expected_entry["name"] + assert name in actual_by_name, f"[{vector_name}] missing named entry {name!r}" + _assert_subset(actual_by_name[name], expected_entry, f"{vector_name}.entries.{name}") + + +@pytest.mark.parametrize("vector", _vectors_for("load-save-reload"), ids=lambda vector: vector["name"]) +def test_named_collection_roundtrip_vectors(vector: dict[str, Any]) -> None: + """Load, save and reload a named collection without changing its canonical form.""" + + name = vector["name"] + collection_path = vector["collectionPath"] + + loaded = Prompty.load(vector["input"]) + saved = loaded.save() + assert collection_path in saved, f"[{name}] missing collection {collection_path!r}" + _assert_collection(name, saved[collection_path], vector["expected"]) + + reloaded = Prompty.load(saved) + resaved = reloaded.save() + assert collection_path in resaved, f"[{name}] reload lost collection {collection_path!r}" + _assert_collection(name, resaved[collection_path], vector["expected"]) + + +@pytest.mark.parametrize("vector", _vectors_for("load-error"), ids=lambda vector: vector["name"]) +def test_named_collection_rejection_vectors(vector: dict[str, Any]) -> None: + """Reject malformed named-collection entry shapes rather than silently coercing them.""" + + with pytest.raises(Exception): # noqa: B017 - backends raise differing concrete types + Prompty.load(vector["input"]) diff --git a/runtime/python/prompty/tests/test_property_scalar_coercion_vectors.py b/runtime/python/prompty/tests/test_property_scalar_coercion_vectors.py new file mode 100644 index 000000000..d5565a0c5 --- /dev/null +++ b/runtime/python/prompty/tests/test_property_scalar_coercion_vectors.py @@ -0,0 +1,29 @@ +"""Validate the canonical atomic Property scalar coercion contract.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from prompty.model import Property + +_VECTORS_PATH = ( + Path(__file__).resolve().parents[4] / "spec" / "vectors" / "model" / "property_scalar_coercion_vectors.json" +) + + +def test_all_primitive_property_scalars_coerce_atomically() -> None: + """Infer and preserve every primitive scalar coercion branch.""" + + document: dict[str, Any] = json.loads(_VECTORS_PATH.read_text(encoding="utf-8")) + vector = document["vectors"][0] + assert vector["name"] == "all_primitive_property_scalars_coerce_atomically" + assert vector["operation"] == "load" + assert [case["name"] for case in vector["cases"]] == ["string", "integer", "float", "boolean"] + + for case in vector["cases"]: + loaded = Property.load(case["input"]) + assert loaded.kind == case["expected"]["kind"], case["name"] + assert type(loaded.example) is type(case["expected"]["example"]), case["name"] + assert loaded.example == case["expected"]["example"], case["name"] diff --git a/runtime/python/prompty/tests/test_responses.py b/runtime/python/prompty/tests/test_responses.py index 86fd8e3c5..6f0190909 100644 --- a/runtime/python/prompty/tests/test_responses.py +++ b/runtime/python/prompty/tests/test_responses.py @@ -231,7 +231,12 @@ def test_basic_schema(self) -> None: assert schema["type"] == "object" assert "temperature" in schema["properties"] assert "condition" in schema["properties"] - assert schema["properties"]["temperature"]["type"] == "integer" + # Under strict mode every property must appear in `required`, so an + # output that is not marked required is expressed as a nullable type + # union. spec/vectors/wire/wire_vectors.json asserts exactly + # `{"type": ["integer", "null"]}` for this same input shape. + assert schema["properties"]["temperature"]["type"] == ["integer", "null"] + assert schema["required"] == ["temperature", "condition"] assert schema["additionalProperties"] is False diff --git a/runtime/python/prompty/tests/test_spec_vectors.py b/runtime/python/prompty/tests/test_spec_vectors.py index 29eba1df6..ef96e1e82 100644 --- a/runtime/python/prompty/tests/test_spec_vectors.py +++ b/runtime/python/prompty/tests/test_spec_vectors.py @@ -497,6 +497,8 @@ def _check_tools(actual: list, expected: list[dict], errors: list[str]): act_bindings = getattr(act, "bindings", []) or [] exp_bindings = exp["bindings"] if isinstance(exp_bindings, dict): + if len(act_bindings) != len(exp_bindings): + errors.append(f" {prefix}.bindings: count {len(act_bindings)} != expected {len(exp_bindings)}") for bname, bval in exp_bindings.items(): found = [b for b in act_bindings if b.name == bname] if not found: @@ -685,6 +687,21 @@ def test_wire_vector(vec: dict): pytest.skip(f"Unknown apiType for wire test: {api_type}") +def _extra_key_errors(exp_body: dict, actual_body: dict) -> list[str]: + """Report top-level keys present in actual that the spec does not declare. + + Without this, a provider emitting an option it has no wire mapping for + (typra #84) passes silently -- see the `anthropic_unmapped_options` wire + vector. This subsumes the per-key absence checks that were previously + hand-enumerated (tools/response_format/text/output_config/system), which + could only ever catch keys someone had already thought to name. + """ + extra = sorted(set(actual_body) - set(exp_body)) + if not extra: + return [] + return [f" Unexpected keys in actual (spec says absent): {extra}"] + + def _check_wire_chat(agent: Prompty, messages: list[Message], exp_body: dict, vec_name: str): """Validate chat wire format.""" # Build the wire representation @@ -704,11 +721,7 @@ def _check_wire_chat(agent: Prompty, messages: list[Message], exp_body: dict, ve actual_body["response_format"] = response_format errors = _dict_subset_match(exp_body, actual_body) - # Also check no extra keys in expected that are absent - if "tools" not in exp_body and "tools" in actual_body: - errors.append(" Unexpected 'tools' key in actual (spec says absent)") - if "response_format" not in exp_body and "response_format" in actual_body: - errors.append(" Unexpected 'response_format' key in actual") + errors.extend(_extra_key_errors(exp_body, actual_body)) if errors: pytest.fail( @@ -731,6 +744,7 @@ def _check_wire_embedding(agent: Prompty, messages: list[Message], exp_body: dic actual_body = executor._build_embedding_args(agent, embed_input) errors = _dict_subset_match(exp_body, actual_body) + errors.extend(_extra_key_errors(exp_body, actual_body)) if errors: pytest.fail( f"Wire vector '{vec_name}' failed:\n" @@ -748,6 +762,7 @@ def _check_wire_image(agent: Prompty, messages: list[Message], exp_body: dict, v actual_body = executor._build_image_args(agent, prompt) errors = _dict_subset_match(exp_body, actual_body) + errors.extend(_extra_key_errors(exp_body, actual_body)) if errors: pytest.fail( f"Wire vector '{vec_name}' failed:\n" @@ -786,10 +801,7 @@ def _check_wire_responses(agent: Prompty, messages: list[Message], exp_body: dic actual_body["text"] = text_config errors = _dict_subset_match(exp_body, actual_body) - if "tools" not in exp_body and "tools" in actual_body: - errors.append(" Unexpected 'tools' key in actual (spec says absent)") - if "text" not in exp_body and "text" in actual_body: - errors.append(" Unexpected 'text' key in actual") + errors.extend(_extra_key_errors(exp_body, actual_body)) if errors: pytest.fail( @@ -805,13 +817,7 @@ def _check_wire_anthropic_chat(agent: Prompty, messages: list[Message], exp_body actual_body = _anthropic_build_chat_args(agent, messages) errors = _dict_subset_match(exp_body, actual_body) - # Check no extra keys in expected that are absent - if "tools" not in exp_body and "tools" in actual_body: - errors.append(" Unexpected 'tools' key in actual (spec says absent)") - if "output_config" not in exp_body and "output_config" in actual_body: - errors.append(" Unexpected 'output_config' key in actual") - if "system" not in exp_body and "system" in actual_body: - errors.append(" Unexpected 'system' key in actual") + errors.extend(_extra_key_errors(exp_body, actual_body)) if errors: pytest.fail( diff --git a/runtime/python/prompty/tests/test_types.py b/runtime/python/prompty/tests/test_types.py new file mode 100644 index 000000000..0e020afd8 --- /dev/null +++ b/runtime/python/prompty/tests/test_types.py @@ -0,0 +1,21 @@ +"""Verify handwritten runtime extensions for generated conversation types.""" + +from __future__ import annotations + +from prompty.core.types import Message, TextPart + + +def test_message_text_parts_are_joined_by_newline() -> None: + """Join multiple text parts according to the canonical method contract.""" + message = Message(role="user", parts=[TextPart(value="first"), TextPart(value="second")]) + + assert message.text == "first\nsecond" + assert message.to_text_content() == "first\nsecond" + + +def test_empty_message_text_content_is_empty_string() -> None: + """Represent an empty all-text message as an empty string.""" + message = Message(role="user", parts=[]) + + assert message.text == "" + assert message.to_text_content() == "" diff --git a/runtime/rust/.env.example b/runtime/rust/.env.example index 217667051..8bf70b078 100644 --- a/runtime/rust/.env.example +++ b/runtime/rust/.env.example @@ -2,7 +2,10 @@ OPENAI_API_KEY= OPENAI_MODEL=gpt-4o-mini OPENAI_EMBEDDING_MODEL=text-embedding-3-small -OPENAI_IMAGE_MODEL=dall-e-2 +# Image generation is opt-in and billable; leave blank to skip those tests. +# Model availability is account-specific: dall-e-2 / dall-e-3 are retired on +# current accounts (400 "does not exist"); newer accounts expose gpt-image-1. +OPENAI_IMAGE_MODEL= # Azure OpenAI / Foundry AZURE_OPENAI_API_KEY= diff --git a/runtime/rust/prompty-anthropic/src/executor.rs b/runtime/rust/prompty-anthropic/src/executor.rs index 4ffa9d36e..0b2250a93 100644 --- a/runtime/rust/prompty-anthropic/src/executor.rs +++ b/runtime/rust/prompty-anthropic/src/executor.rs @@ -24,8 +24,8 @@ impl Executor for AnthropicExecutor { async fn execute(&self, agent: &Prompty, messages: &[Message]) -> Result { let api_type = agent .model - .api_type .as_ref() + .and_then(|model| model.api_type.as_ref()) .map(|t| t.as_str()) .unwrap_or("chat"); if api_type != "chat" && api_type != "agent" { @@ -96,8 +96,8 @@ impl Executor for AnthropicExecutor { ) -> Result + Send>>, InvokerError> { let api_type = agent .model - .api_type .as_ref() + .and_then(|model| model.api_type.as_ref()) .map(|t| t.as_str()) .unwrap_or("chat"); if api_type != "chat" && api_type != "agent" { @@ -148,8 +148,8 @@ impl AnthropicExecutor { pub fn build_args(agent: &Prompty, messages: &[Message]) -> Result { let api_type = agent .model - .api_type .as_ref() + .and_then(|model| model.api_type.as_ref()) .map(|t| t.as_str()) .unwrap_or("chat"); if api_type != "chat" && api_type != "agent" { @@ -171,7 +171,10 @@ impl AnthropicExecutor { fn resolve_connection( agent: &Prompty, ) -> Result, InvokerError> { - let conn = &agent.model.connection; + let conn = match agent.model.as_ref() { + Some(model) => &model.connection, + None => &serde_json::Value::Null, + }; let kind = conn.get("kind").and_then(|k| k.as_str()).unwrap_or(""); if kind == "reference" { diff --git a/runtime/rust/prompty-anthropic/src/processor.rs b/runtime/rust/prompty-anthropic/src/processor.rs index a4c8fcd62..d41b6a8e1 100644 --- a/runtime/rust/prompty-anthropic/src/processor.rs +++ b/runtime/rust/prompty-anthropic/src/processor.rs @@ -526,7 +526,19 @@ mod tests { .process_with_context( &agent, response, - &ModelInvocationRequest::load_from_value(&json!({}), &LoadContext::default()), + &ModelInvocationRequest::load_from_value( + &json!({ + "context": { + "id": "context:inv_test", + "sessionId": "sess_test", + "turnId": "turn_test", + "invocationId": "inv_test", + "iteration": 0, + "contextState": {} + } + }), + &LoadContext::default(), + ), ) .await .unwrap(); diff --git a/runtime/rust/prompty-anthropic/src/wire.rs b/runtime/rust/prompty-anthropic/src/wire.rs index f8ab4daf6..b69353a3d 100644 --- a/runtime/rust/prompty-anthropic/src/wire.rs +++ b/runtime/rust/prompty-anthropic/src/wire.rs @@ -50,7 +50,16 @@ pub fn build_chat_args(agent: &Prompty, messages: &[Message]) -> Result Value { fn apply_options(agent: &Prompty, body: &mut Map) { let mut max_tokens = DEFAULT_MAX_TOKENS; - if let Some(opts) = &agent.model.options { + if let Some(opts) = agent + .model + .as_ref() + .and_then(|model| model.options.as_ref()) + { let wire = opts.to_wire("anthropic"); if let Value::Object(map) = wire { for (k, v) in map { @@ -349,13 +362,13 @@ fn property_to_json_schema(prop: &Property) -> Result { if let Some(ref desc) = prop.description { schema.insert("description".into(), json!(desc)); } - if let Some(ref enum_vals) = prop.enum_values { - schema.insert("enum".into(), Value::Array(enum_vals.clone())); + if !prop.enum_values.is_empty() { + schema.insert("enum".into(), Value::Array(prop.enum_values.clone())); } match &prop.kind { PropertyKind::Array { items } => { - if !items.is_null() { + if let Some(items) = items.as_ref().filter(|value| !value.is_null()) { let ctx = prompty::model::context::LoadContext::default(); let item_prop = Property::load_from_value(items, &ctx); schema.insert("items".into(), property_to_json_schema(&item_prop)?); @@ -385,23 +398,27 @@ fn property_to_json_schema(prop: &Property) -> Result { } schema.insert("additionalProperties".into(), Value::Bool(false)); } - PropertyKind::Union { one_of, any_of } => match (!one_of.is_empty(), !any_of.is_empty()) { - (true, false) => { - let branches = one_of - .iter() - .map(property_to_json_schema) - .collect::, _>>()?; - schema.insert("oneOf".into(), Value::Array(branches)); - } - (false, true) => { - let branches = any_of - .iter() - .map(property_to_json_schema) - .collect::, _>>()?; - schema.insert("anyOf".into(), Value::Array(branches)); + PropertyKind::Union { one_of, any_of } => { + let one_of = one_of.as_deref().unwrap_or(&[]); + let any_of = any_of.as_deref().unwrap_or(&[]); + match (!one_of.is_empty(), !any_of.is_empty()) { + (true, false) => { + let branches = one_of + .iter() + .map(property_to_json_schema) + .collect::, _>>()?; + schema.insert("oneOf".into(), Value::Array(branches)); + } + (false, true) => { + let branches = any_of + .iter() + .map(property_to_json_schema) + .collect::, _>>()?; + schema.insert("anyOf".into(), Value::Array(branches)); + } + _ => return Err(SchemaError::invalid_union()), } - _ => return Err(SchemaError::invalid_union()), - }, + } _ => {} } diff --git a/runtime/rust/prompty-anthropic/tests/integration.rs b/runtime/rust/prompty-anthropic/tests/integration.rs index ff4e34840..c0338e4b3 100644 --- a/runtime/rust/prompty-anthropic/tests/integration.rs +++ b/runtime/rust/prompty-anthropic/tests/integration.rs @@ -199,11 +199,9 @@ async fn test_anthropic_agent_tool_calling() { "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { "name": "city", "kind": "string", "description": "The city name", "required": true } - ] - }, + "parameters": [ + { "name": "city", "kind": "string", "description": "The city name", "required": true } + ], } ], "instructions": "system:\nYou are a helpful assistant with weather tools. Use the get_weather tool when asked about weather. Be brief.\nuser:\nWhat is the weather in Seattle?", diff --git a/runtime/rust/prompty-anthropic/tests/vectors.rs b/runtime/rust/prompty-anthropic/tests/vectors.rs index 7b7cad4b6..a85e5affe 100644 --- a/runtime/rust/prompty-anthropic/tests/vectors.rs +++ b/runtime/rust/prompty-anthropic/tests/vectors.rs @@ -209,6 +209,7 @@ wire_test!(anthropic_max_tokens_required); wire_test!(anthropic_image_format); wire_test!(anthropic_tool_wire); wire_test!(anthropic_options); +wire_test!(anthropic_unmapped_options); // --------------------------------------------------------------------------- // Process vector tests diff --git a/runtime/rust/prompty-foundry/src/executor.rs b/runtime/rust/prompty-foundry/src/executor.rs index 1d3a582a6..849aa9d40 100644 --- a/runtime/rust/prompty-foundry/src/executor.rs +++ b/runtime/rust/prompty-foundry/src/executor.rs @@ -36,8 +36,8 @@ impl Executor for FoundryExecutor { async fn execute(&self, agent: &Prompty, messages: &[Message]) -> Result { let api_type = agent .model - .api_type .as_ref() + .and_then(|model| model.api_type.as_ref()) .map(|t| t.as_str()) .unwrap_or("chat"); @@ -101,8 +101,8 @@ impl Executor for FoundryExecutor { ) -> Result + Send>>, InvokerError> { let api_type = agent .model - .api_type .as_ref() + .and_then(|model| model.api_type.as_ref()) .map(|t| t.as_str()) .unwrap_or("chat"); if api_type != "chat" && api_type != "agent" { @@ -155,7 +155,10 @@ impl Executor for FoundryExecutor { fn resolve_connection( agent: &Prompty, ) -> Result, InvokerError> { - let conn = &agent.model.connection; + let conn = match agent.model.as_ref() { + Some(model) => &model.connection, + None => &serde_json::Value::Null, + }; let kind = conn.get("kind").and_then(|k| k.as_str()).unwrap_or(""); if kind == "reference" { @@ -278,8 +281,13 @@ fn strip_project_path(endpoint: &str) -> String { /// Extract the deployment name from the agent's model configuration. fn get_deployment(agent: &Prompty) -> Result { // model.id is the deployment name for Azure - if !agent.model.id.is_empty() { - return Ok(agent.model.id.clone()); + if let Some(id) = agent + .model + .as_ref() + .map(|model| model.id.as_str()) + .filter(|id| !id.is_empty()) + { + return Ok(id.to_string()); } // Fall back to environment variable @@ -299,7 +307,11 @@ fn get_deployment(agent: &Prompty) -> Result { /// Get the API version, defaulting to the latest preview. fn get_api_version(agent: &Prompty) -> String { // Check model options for custom api version - if let Some(opts) = &agent.model.options { + if let Some(opts) = agent + .model + .as_ref() + .and_then(|model| model.options.as_ref()) + { if let Some(version) = opts .additional_properties .get("apiVersion") diff --git a/runtime/rust/prompty-foundry/src/processor.rs b/runtime/rust/prompty-foundry/src/processor.rs index 72bd6f56d..d37a49cd7 100644 --- a/runtime/rust/prompty-foundry/src/processor.rs +++ b/runtime/rust/prompty-foundry/src/processor.rs @@ -128,7 +128,19 @@ mod tests { .process_with_context( &agent, response, - &ModelInvocationRequest::load_from_value(&json!({}), &LoadContext::default()), + &ModelInvocationRequest::load_from_value( + &json!({ + "context": { + "id": "context:inv_test", + "sessionId": "sess_test", + "turnId": "turn_test", + "invocationId": "inv_test", + "iteration": 0, + "contextState": {} + } + }), + &LoadContext::default(), + ), ) .await .unwrap(); diff --git a/runtime/rust/prompty-foundry/tests/entra_id.rs b/runtime/rust/prompty-foundry/tests/entra_id.rs index e57c6f1e7..fe3a6e024 100644 --- a/runtime/rust/prompty-foundry/tests/entra_id.rs +++ b/runtime/rust/prompty-foundry/tests/entra_id.rs @@ -286,11 +286,9 @@ async fn test_entra_id_agent_tool_calling() { "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { "name": "city", "kind": "string", "description": "The city name", "required": true } - ] - }, + "parameters": [ + { "name": "city", "kind": "string", "description": "The city name", "required": true } + ], } ], "instructions": "system:\nYou are a helpful assistant with weather tools. Use the get_weather tool when asked about weather. Be brief.\nuser:\nWhat is the weather in Seattle?", diff --git a/runtime/rust/prompty-foundry/tests/integration.rs b/runtime/rust/prompty-foundry/tests/integration.rs index 93ad414ae..4286bed2c 100644 --- a/runtime/rust/prompty-foundry/tests/integration.rs +++ b/runtime/rust/prompty-foundry/tests/integration.rs @@ -264,11 +264,9 @@ async fn test_azure_agent_tool_calling() { "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { "name": "city", "kind": "string", "description": "The city name", "required": true } - ] - }, + "parameters": [ + { "name": "city", "kind": "string", "description": "The city name", "required": true } + ], } ], "instructions": "system:\nYou are a helpful assistant with weather tools. Use the get_weather tool when asked about weather. Be brief.\nuser:\nWhat is the weather in Seattle?", diff --git a/runtime/rust/prompty-openai/src/executor.rs b/runtime/rust/prompty-openai/src/executor.rs index 92f7b7e0f..82d527c7c 100644 --- a/runtime/rust/prompty-openai/src/executor.rs +++ b/runtime/rust/prompty-openai/src/executor.rs @@ -119,8 +119,8 @@ impl OpenAIExecutor { ) -> Result { let api_type = agent .model - .api_type .as_ref() + .and_then(|model| model.api_type.as_ref()) .map(|t| t.as_str()) .unwrap_or("chat"); @@ -176,8 +176,8 @@ impl OpenAIExecutor { ) -> Result + Send>>, InvokerError> { let api_type = agent .model - .api_type .as_ref() + .and_then(|model| model.api_type.as_ref()) .map(|t| t.as_str()) .unwrap_or("chat"); @@ -233,8 +233,8 @@ impl OpenAIExecutor { ) -> Result { let api_type = agent .model - .api_type .as_ref() + .and_then(|model| model.api_type.as_ref()) .map(|t| t.as_str()) .unwrap_or("chat"); Ok(match api_type { @@ -348,7 +348,10 @@ fn responses_continuation( fn resolve_connection( agent: &Prompty, ) -> Result, InvokerError> { - let conn = &agent.model.connection; + let conn = match agent.model.as_ref() { + Some(model) => &model.connection, + None => &serde_json::Value::Null, + }; let kind = conn.get("kind").and_then(|k| k.as_str()).unwrap_or(""); if kind == "reference" { diff --git a/runtime/rust/prompty-openai/src/wire.rs b/runtime/rust/prompty-openai/src/wire.rs index e23512b1b..2f3fe5232 100644 --- a/runtime/rust/prompty-openai/src/wire.rs +++ b/runtime/rust/prompty-openai/src/wire.rs @@ -125,19 +125,37 @@ fn mime_to_audio_format(mime: &str) -> String { // Build request arguments // --------------------------------------------------------------------------- +/// `Prompty.model` is optional in the schema, so an absent model is treated the +/// same as one carrying no id and no options — preserving the per-endpoint +/// empty-id fallbacks below. +fn model_id(agent: &Prompty) -> String { + agent + .model + .as_ref() + .map(|model| model.id.clone()) + .unwrap_or_default() +} + +fn model_options(agent: &Prompty) -> Option<&ModelOptions> { + agent + .model + .as_ref() + .and_then(|model| model.options.as_ref()) +} + /// Build the full request body for a chat completions call. pub fn build_chat_args(agent: &Prompty, messages: &[Message]) -> Result { let mut args = Map::new(); // Model ID - args.insert("model".to_string(), Value::String(agent.model.id.clone())); + args.insert("model".to_string(), Value::String(model_id(agent))); // Messages let wire_msgs: Vec = messages.iter().map(message_to_wire).collect(); args.insert("messages".to_string(), Value::Array(wire_msgs)); // Options - apply_options(&mut args, &agent.model.options); + apply_options(&mut args, model_options(agent)); // Tools let tools = tools_to_wire(agent)?; @@ -174,10 +192,9 @@ pub fn enable_streaming(body: &mut Value, api_type: &str) { /// Build the request body for an embedding call. pub fn build_embedding_args(agent: &Prompty, messages: &[Message]) -> Value { - let model = if agent.model.id.is_empty() { - "text-embedding-ada-002".to_string() - } else { - agent.model.id.clone() + let model = match model_id(agent) { + id if id.is_empty() => "text-embedding-ada-002".to_string(), + id => id, }; let input = extract_text_input(messages); @@ -188,7 +205,7 @@ pub fn build_embedding_args(agent: &Prompty, messages: &[Message]) -> Value { }); // Only additionalProperties from options - if let Some(ref opts) = agent.model.options { + if let Some(opts) = model_options(agent) { if let Some(map) = opts.additional_properties.as_object() { for (k, v) in map { args[k.clone()] = v.clone(); @@ -201,10 +218,9 @@ pub fn build_embedding_args(agent: &Prompty, messages: &[Message]) -> Value { /// Build the request body for an image generation call. pub fn build_image_args(agent: &Prompty, messages: &[Message]) -> Value { - let model = if agent.model.id.is_empty() { - "dall-e-3".to_string() - } else { - agent.model.id.clone() + let model = match model_id(agent) { + id if id.is_empty() => "dall-e-3".to_string(), + id => id, }; let prompt = extract_text_input(messages); @@ -224,7 +240,7 @@ pub fn build_image_args(agent: &Prompty, messages: &[Message]) -> Value { }); // Only additionalProperties from options - if let Some(ref opts) = agent.model.options { + if let Some(opts) = model_options(agent) { if let Some(map) = opts.additional_properties.as_object() { for (k, v) in map { args[k.clone()] = v.clone(); @@ -267,7 +283,7 @@ fn fix_f32_value(v: Value) -> Value { v } -fn apply_options(args: &mut Map, opts: &Option) { +fn apply_options(args: &mut Map, opts: Option<&ModelOptions>) { let Some(opts) = opts else { return }; let wire = opts.to_wire("openai"); @@ -358,12 +374,12 @@ fn property_to_json_schema(prop: &Property, strict: bool) -> Result { + PropertyKind::Array { items: Some(items) } if !items.is_null() => { let ctx = prompty::model::context::LoadContext::default(); let item_prop = Property::load_from_value(items, &ctx); schema.insert( @@ -398,17 +414,21 @@ fn property_to_json_schema(prop: &Property, strict: bool) -> Result { // bare {"type": "object"} when properties is empty or absent } - PropertyKind::Union { one_of, any_of } => match (!one_of.is_empty(), !any_of.is_empty()) { - (true, false) => return Err(SchemaError::unsupported_one_of()), - (false, true) => { - let branches = any_of - .iter() - .map(|branch| property_to_json_schema(branch, strict)) - .collect::, _>>()?; - schema.insert("anyOf".to_string(), Value::Array(branches)); + PropertyKind::Union { one_of, any_of } => { + let one_of = one_of.as_deref().unwrap_or(&[]); + let any_of = any_of.as_deref().unwrap_or(&[]); + match (!one_of.is_empty(), !any_of.is_empty()) { + (true, false) => return Err(SchemaError::unsupported_one_of()), + (false, true) => { + let branches = any_of + .iter() + .map(|branch| property_to_json_schema(branch, strict)) + .collect::, _>>()?; + schema.insert("anyOf".to_string(), Value::Array(branches)); + } + _ => return Err(SchemaError::invalid_union()), } - _ => return Err(SchemaError::invalid_union()), - }, + } _ => {} } @@ -548,10 +568,9 @@ fn output_schema_to_wire(agent: &Prompty) -> Result, SchemaError> /// /// System/developer messages become `instructions`; other messages become `input` items. pub fn build_responses_args(agent: &Prompty, messages: &[Message]) -> Result { - let model = if agent.model.id.is_empty() { - "gpt-4o".to_string() - } else { - agent.model.id.clone() + let model = match model_id(agent) { + id if id.is_empty() => "gpt-4o".to_string(), + id => id, }; let mut system_parts: Vec = Vec::new(); @@ -578,7 +597,7 @@ pub fn build_responses_args(agent: &Prompty, messages: &[Message]) -> Result bool { msg.metadata.get("responses_function_call").is_some() } -fn apply_responses_options(args: &mut Map, opts: &Option) { +fn apply_responses_options(args: &mut Map, opts: Option<&ModelOptions>) { let Some(opts) = opts else { return }; let wire = opts.to_wire("responses"); diff --git a/runtime/rust/prompty-openai/tests/integration.rs b/runtime/rust/prompty-openai/tests/integration.rs index 3742a0306..74d53748e 100644 --- a/runtime/rust/prompty-openai/tests/integration.rs +++ b/runtime/rust/prompty-openai/tests/integration.rs @@ -373,11 +373,9 @@ async fn test_agent_tool_calling() { "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { "name": "city", "kind": "string", "description": "The city name", "required": true } - ] - }, + "parameters": [ + { "name": "city", "kind": "string", "description": "The city name", "required": true } + ], } ], "instructions": "system:\nYou are a helpful assistant with weather tools. Use the get_weather tool when asked about weather. Be brief.\nuser:\nWhat is the weather in Seattle?", diff --git a/runtime/rust/prompty-openai/tests/wire_vectors.rs b/runtime/rust/prompty-openai/tests/wire_vectors.rs index fecbb32f2..9369077b5 100644 --- a/runtime/rust/prompty-openai/tests/wire_vectors.rs +++ b/runtime/rust/prompty-openai/tests/wire_vectors.rs @@ -206,6 +206,7 @@ wire_test!(chat_image_base64); wire_test!(responses_simple); wire_test!(responses_with_tools); wire_test!(responses_structured_output); +wire_test!(responses_unmapped_options); fn function_parameters_schema(parameters: Value) -> Result { let agent = Prompty::load_from_value( diff --git a/runtime/rust/prompty/src/loader.rs b/runtime/rust/prompty/src/loader.rs index fd336767c..bcf59851f 100644 --- a/runtime/rust/prompty/src/loader.rs +++ b/runtime/rust/prompty/src/loader.rs @@ -184,7 +184,17 @@ fn build_agent(raw: &str, file_path: &Path, options: &LoadOptions) -> Result Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load GuardrailResult from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl GuardrailResult { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { allowed: value .get("allowed") @@ -58,6 +65,10 @@ impl GuardrailResult { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize GuardrailResult to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -65,10 +76,10 @@ impl GuardrailResult { let mut result = serde_json::Map::new(); // Write base fields result.insert("allowed".to_string(), serde_json::Value::Bool(self.allowed)); - if let Some(ref val) = self.reason { + if let Some(val) = self.reason.as_ref() { result.insert("reason".to_string(), serde_json::Value::String(val.clone())); } - if let Some(ref val) = self.rewrite { + if let Some(val) = self.rewrite.as_ref() { result.insert("rewrite".to_string(), val.clone()); } ctx.process_dict(serde_json::Value::Object(result)) @@ -120,6 +131,7 @@ impl serde::Serialize for GuardrailResult { impl<'de> serde::Deserialize<'de> for GuardrailResult { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/agent/prompty.rs b/runtime/rust/prompty/src/model/agent/prompty.rs index bf25ed3f7..a81edd74f 100644 --- a/runtime/rust/prompty/src/model/agent/prompty.rs +++ b/runtime/rust/prompty/src/model/agent/prompty.rs @@ -28,14 +28,14 @@ pub struct Prompty { pub display_name: Option, /// Description of the prompt's purpose pub description: Option, - /// Additional metadata including authors, tags, and other arbitrary properties + /// Additional metadata including authors, tags, and other arbitrary properties. Values may be explicit null. pub metadata: serde_json::Value, /// Input parameters that participate in template rendering - pub inputs: Vec, + pub inputs: Option>, /// Expected output format and structure - pub outputs: Vec, + pub outputs: Option>, /// AI model configuration - pub model: Model, + pub model: Option, /// Tools available for extended functionality pub tools: Vec, /// Template configuration for prompt rendering @@ -53,12 +53,16 @@ impl Prompty { /// Load Prompty from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load Prompty from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -67,6 +71,9 @@ impl Prompty { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { name: value .get("name") @@ -85,19 +92,12 @@ impl Prompty { .get("metadata") .cloned() .unwrap_or(serde_json::Value::Null), - inputs: value - .get("inputs") - .map(|v| Self::load_inputs(v, ctx)) - .unwrap_or_default(), - outputs: value - .get("outputs") - .map(|v| Self::load_outputs(v, ctx)) - .unwrap_or_default(), + inputs: value.get("inputs").map(|v| Self::load_inputs(v, ctx)), + outputs: value.get("outputs").map(|v| Self::load_outputs(v, ctx)), model: value .get("model") .filter(|v| v.is_object() || v.is_array() || v.is_string()) - .map(|v| Model::load_from_value(v, ctx)) - .unwrap_or_default(), + .map(|v| Model::load_from_value(v, ctx)), tools: value .get("tools") .map(|v| Self::load_tools(v, ctx)) @@ -113,6 +113,134 @@ impl Prompty { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(collection) = value.get("inputs") { + let collection_path = if path.is_empty() { + "inputs".to_string() + } else { + format!("{}.inputs", path) + }; + match collection { + serde_json::Value::Object(entries) => { + for (name, entry) in entries { + let entry_path = format!("{}.{}", collection_path, name); + if entry.is_array() { + return Err(format!( + "{}: invalid named collection entry category array", + entry_path + )); + } + let mut candidate = if entry.is_object() { + entry.clone() + } else { + serde_json::json!({ "kind": entry }) + }; + if let serde_json::Value::Object(ref mut map) = candidate { + map.insert("name".to_string(), serde_json::Value::String(name.clone())); + } + Property::validate_input_at(&candidate, &entry_path)?; + } + } + serde_json::Value::Array(entries) => { + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Property::validate_input_at(entry, &entry_path)?; + } + } + _ => {} + } + } + if let Some(collection) = value.get("outputs") { + let collection_path = if path.is_empty() { + "outputs".to_string() + } else { + format!("{}.outputs", path) + }; + match collection { + serde_json::Value::Object(entries) => { + for (name, entry) in entries { + let entry_path = format!("{}.{}", collection_path, name); + if entry.is_array() { + return Err(format!( + "{}: invalid named collection entry category array", + entry_path + )); + } + let mut candidate = if entry.is_object() { + entry.clone() + } else { + serde_json::json!({ "kind": entry }) + }; + if let serde_json::Value::Object(ref mut map) = candidate { + map.insert("name".to_string(), serde_json::Value::String(name.clone())); + } + Property::validate_input_at(&candidate, &entry_path)?; + } + } + serde_json::Value::Array(entries) => { + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Property::validate_input_at(entry, &entry_path)?; + } + } + _ => {} + } + } + let child_path = if path.is_empty() { + "model".to_string() + } else { + format!("{}.model", path) + }; + if let Some(child) = value.get("model") { + Model::validate_input_at(child, &child_path)?; + } + if let Some(collection) = value.get("tools") { + let collection_path = if path.is_empty() { + "tools".to_string() + } else { + format!("{}.tools", path) + }; + match collection { + serde_json::Value::Object(entries) => { + for (name, entry) in entries { + let entry_path = format!("{}.{}", collection_path, name); + if entry.is_array() { + return Err(format!( + "{}: invalid named collection entry category array", + entry_path + )); + } + let mut candidate = if entry.is_object() { + entry.clone() + } else { + serde_json::json!({ "kind": entry }) + }; + if let serde_json::Value::Object(ref mut map) = candidate { + map.insert("name".to_string(), serde_json::Value::String(name.clone())); + } + Tool::validate_input_at(&candidate, &entry_path)?; + } + } + serde_json::Value::Array(entries) => { + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Tool::validate_input_at(entry, &entry_path)?; + } + } + _ => {} + } + } + let child_path = if path.is_empty() { + "template".to_string() + } else { + format!("{}.template", path) + }; + if let Some(child) = value.get("template") { + Template::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize Prompty to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -125,13 +253,13 @@ impl Prompty { serde_json::Value::String(self.name.clone()), ); } - if let Some(ref val) = self.display_name { + if let Some(val) = self.display_name.as_ref() { result.insert( "displayName".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.description { + if let Some(val) = self.description.as_ref() { result.insert( "description".to_string(), serde_json::Value::String(val.clone()), @@ -140,31 +268,26 @@ impl Prompty { if !self.metadata.is_null() { result.insert("metadata".to_string(), self.metadata.clone()); } - if !self.inputs.is_empty() { - result.insert("inputs".to_string(), Self::save_inputs(&self.inputs, ctx)); + if let Some(items) = self.inputs.as_ref() { + result.insert("inputs".to_string(), Self::save_inputs(items, ctx)); } - if !self.outputs.is_empty() { - result.insert( - "outputs".to_string(), - Self::save_outputs(&self.outputs, ctx), - ); + if let Some(items) = self.outputs.as_ref() { + result.insert("outputs".to_string(), Self::save_outputs(items, ctx)); } - { - let nested = self.model.to_value(ctx); + if let Some(val) = self.model.as_ref() { + let nested = val.to_value(ctx); if !nested.is_null() { result.insert("model".to_string(), nested); } } - if !self.tools.is_empty() { - result.insert("tools".to_string(), Self::save_tools(&self.tools, ctx)); - } - if let Some(ref val) = self.template { + result.insert("tools".to_string(), Self::save_tools(&self.tools, ctx)); + if let Some(val) = self.template.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("template".to_string(), nested); } } - if let Some(ref val) = self.instructions { + if let Some(val) = self.instructions.as_ref() { result.insert( "instructions".to_string(), serde_json::Value::String(val.clone()), @@ -199,20 +322,31 @@ impl Prompty { serde_json::Value::Object(obj) => obj .iter() - .filter_map(|(name, value)| { + .map(|(name, value)| { if value.is_array() { - return None; + panic!( + "inputs.{}: invalid named collection entry category array", + name + ); } let mut v = if value.is_object() { value.clone() + } else if value.is_i64() { + serde_json::json!({ "kind": "integer", "default": value }) + } else if value.is_f64() { + serde_json::json!({ "kind": "float", "default": value }) + } else if value.is_string() { + serde_json::json!({ "kind": "string", "default": value }) + } else if value.is_boolean() { + serde_json::json!({ "kind": "boolean", "default": value }) } else { - serde_json::json!({ "kind": value }) + serde_json::json!({ "default": value }) }; if let serde_json::Value::Object(ref mut m) = v { m.entry("name".to_string()) .or_insert_with(|| serde_json::Value::String(name.clone())); } - Some(Property::load_from_value(&v, ctx)) + Property::load_from_value(&v, ctx) }) .collect(), _ => Vec::new(), @@ -221,18 +355,35 @@ impl Prompty { /// Save a collection of Property to a JSON value. fn save_inputs(items: &[Property], ctx: &SaveContext) -> serde_json::Value { + let mut serialized = items + .iter() + .map(|item| item.to_value(ctx)) + .collect::>(); + for item_data in &mut serialized { + if let serde_json::Value::Object(map) = item_data { + if matches!(map.get("name"), Some(serde_json::Value::String(name)) if name.is_empty()) + { + map.remove("name"); + } + } + } + if ctx.collection_format == "array" { - return serde_json::Value::Array( - items - .iter() - .map(|item| item.to_value(ctx)) - .collect::>(), - ); + return serde_json::Value::Array(serialized); + } + let mut names = std::collections::HashSet::new(); + for item_data in &serialized { + let Some(name) = item_data.get("name").and_then(|value| value.as_str()) else { + return serde_json::Value::Array(serialized); + }; + if name.is_empty() || !names.insert(name.to_string()) { + return serde_json::Value::Array(serialized); + } } // Object format: use name as key let mut result = serde_json::Map::new(); - for item in items { - let mut item_data = match item.to_value(ctx) { + for item_data in serialized { + let mut item_data = match item_data { serde_json::Value::Object(m) => m, other => { let mut m = serde_json::Map::new(); @@ -240,9 +391,19 @@ impl Prompty { m } }; - if let Some(serde_json::Value::String(name)) = item_data.remove("name") { - result.insert(name, serde_json::Value::Object(item_data)); + let serde_json::Value::String(name) = item_data + .remove("name") + .expect("validated named collection item") + else { + unreachable!() + }; + if ctx.use_shorthand && item_data.len() == 1 { + if let Some(shorthand) = item_data.get("example") { + result.insert(name, shorthand.clone()); + continue; + } } + result.insert(name, serde_json::Value::Object(item_data)); } serde_json::Value::Object(result) } @@ -258,20 +419,31 @@ impl Prompty { serde_json::Value::Object(obj) => obj .iter() - .filter_map(|(name, value)| { + .map(|(name, value)| { if value.is_array() { - return None; + panic!( + "outputs.{}: invalid named collection entry category array", + name + ); } let mut v = if value.is_object() { value.clone() + } else if value.is_i64() { + serde_json::json!({ "kind": "integer", "default": value }) + } else if value.is_f64() { + serde_json::json!({ "kind": "float", "default": value }) + } else if value.is_string() { + serde_json::json!({ "kind": "string", "default": value }) + } else if value.is_boolean() { + serde_json::json!({ "kind": "boolean", "default": value }) } else { - serde_json::json!({ "kind": value }) + serde_json::json!({ "default": value }) }; if let serde_json::Value::Object(ref mut m) = v { m.entry("name".to_string()) .or_insert_with(|| serde_json::Value::String(name.clone())); } - Some(Property::load_from_value(&v, ctx)) + Property::load_from_value(&v, ctx) }) .collect(), _ => Vec::new(), @@ -280,18 +452,35 @@ impl Prompty { /// Save a collection of Property to a JSON value. fn save_outputs(items: &[Property], ctx: &SaveContext) -> serde_json::Value { + let mut serialized = items + .iter() + .map(|item| item.to_value(ctx)) + .collect::>(); + for item_data in &mut serialized { + if let serde_json::Value::Object(map) = item_data { + if matches!(map.get("name"), Some(serde_json::Value::String(name)) if name.is_empty()) + { + map.remove("name"); + } + } + } + if ctx.collection_format == "array" { - return serde_json::Value::Array( - items - .iter() - .map(|item| item.to_value(ctx)) - .collect::>(), - ); + return serde_json::Value::Array(serialized); + } + let mut names = std::collections::HashSet::new(); + for item_data in &serialized { + let Some(name) = item_data.get("name").and_then(|value| value.as_str()) else { + return serde_json::Value::Array(serialized); + }; + if name.is_empty() || !names.insert(name.to_string()) { + return serde_json::Value::Array(serialized); + } } // Object format: use name as key let mut result = serde_json::Map::new(); - for item in items { - let mut item_data = match item.to_value(ctx) { + for item_data in serialized { + let mut item_data = match item_data { serde_json::Value::Object(m) => m, other => { let mut m = serde_json::Map::new(); @@ -299,9 +488,19 @@ impl Prompty { m } }; - if let Some(serde_json::Value::String(name)) = item_data.remove("name") { - result.insert(name, serde_json::Value::Object(item_data)); + let serde_json::Value::String(name) = item_data + .remove("name") + .expect("validated named collection item") + else { + unreachable!() + }; + if ctx.use_shorthand && item_data.len() == 1 { + if let Some(shorthand) = item_data.get("example") { + result.insert(name, shorthand.clone()); + continue; + } } + result.insert(name, serde_json::Value::Object(item_data)); } serde_json::Value::Object(result) } @@ -316,9 +515,12 @@ impl Prompty { serde_json::Value::Object(obj) => obj .iter() - .filter_map(|(name, value)| { + .map(|(name, value)| { if value.is_array() { - return None; + panic!( + "tools.{}: invalid named collection entry category array", + name + ); } let mut v = if value.is_object() { value.clone() @@ -329,7 +531,7 @@ impl Prompty { m.entry("name".to_string()) .or_insert_with(|| serde_json::Value::String(name.clone())); } - Some(Tool::load_from_value(&v, ctx)) + Tool::load_from_value(&v, ctx) }) .collect(), _ => Vec::new(), @@ -338,18 +540,35 @@ impl Prompty { /// Save a collection of Tool to a JSON value. fn save_tools(items: &[Tool], ctx: &SaveContext) -> serde_json::Value { + let mut serialized = items + .iter() + .map(|item| item.to_value(ctx)) + .collect::>(); + for item_data in &mut serialized { + if let serde_json::Value::Object(map) = item_data { + if matches!(map.get("name"), Some(serde_json::Value::String(name)) if name.is_empty()) + { + map.remove("name"); + } + } + } + if ctx.collection_format == "array" { - return serde_json::Value::Array( - items - .iter() - .map(|item| item.to_value(ctx)) - .collect::>(), - ); + return serde_json::Value::Array(serialized); + } + let mut names = std::collections::HashSet::new(); + for item_data in &serialized { + let Some(name) = item_data.get("name").and_then(|value| value.as_str()) else { + return serde_json::Value::Array(serialized); + }; + if name.is_empty() || !names.insert(name.to_string()) { + return serde_json::Value::Array(serialized); + } } // Object format: use name as key let mut result = serde_json::Map::new(); - for item in items { - let mut item_data = match item.to_value(ctx) { + for item_data in serialized { + let mut item_data = match item_data { serde_json::Value::Object(m) => m, other => { let mut m = serde_json::Map::new(); @@ -357,9 +576,13 @@ impl Prompty { m } }; - if let Some(serde_json::Value::String(name)) = item_data.remove("name") { - result.insert(name, serde_json::Value::Object(item_data)); - } + let serde_json::Value::String(name) = item_data + .remove("name") + .expect("validated named collection item") + else { + unreachable!() + }; + result.insert(name, serde_json::Value::Object(item_data)); } serde_json::Value::Object(result) } @@ -377,6 +600,7 @@ impl serde::Serialize for Prompty { impl<'de> serde::Deserialize<'de> for Prompty { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/connection/authorization_code_flow.rs b/runtime/rust/prompty/src/model/connection/authorization_code_flow.rs index 28830e06f..912828676 100644 --- a/runtime/rust/prompty/src/model/connection/authorization_code_flow.rs +++ b/runtime/rust/prompty/src/model/connection/authorization_code_flow.rs @@ -29,12 +29,16 @@ impl AuthorizationCodeFlow { /// Load AuthorizationCodeFlow from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load AuthorizationCodeFlow from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -43,6 +47,9 @@ impl AuthorizationCodeFlow { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { auth_url: value .get("authUrl") @@ -57,6 +64,10 @@ impl AuthorizationCodeFlow { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize AuthorizationCodeFlow to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -128,6 +139,7 @@ impl serde::Serialize for AuthorizationCodeFlow { impl<'de> serde::Deserialize<'de> for AuthorizationCodeFlow { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/connection/connection.rs b/runtime/rust/prompty/src/model/connection/connection.rs index 37b7ae9d8..360f99e1d 100644 --- a/runtime/rust/prompty/src/model/connection/connection.rs +++ b/runtime/rust/prompty/src/model/connection/connection.rs @@ -125,6 +125,13 @@ pub enum ConnectionKind { /// The connection type within the Foundry project (e.g., 'model', 'index', 'storage') connection_type: Option, }, + /// Lossless fallback for unrecognized `kind` values. + Unknown { + /// The raw `kind` string for this unknown variant. + kind_name: String, + /// Unmodeled fields preserved for forward-compatible round trips. + raw: serde_json::Map, + }, } impl Default for ConnectionKind { @@ -155,12 +162,16 @@ impl Connection { /// Load Connection from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load Connection from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -169,6 +180,9 @@ impl Connection { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } let kind_str = value.get("kind").and_then(|v| v.as_str()).unwrap_or(""); let kind = match kind_str { "reference" => ConnectionKind::Reference { @@ -255,7 +269,16 @@ impl Connection { .and_then(|v| v.as_str()) .map(|s| s.to_string()), }, - _ => ConnectionKind::default(), + _ => ConnectionKind::Unknown { + kind_name: kind_str.to_string(), + raw: { + let mut raw = value.as_object().cloned().unwrap_or_default(); + raw.remove("kind"); + raw.remove("authenticationMode"); + raw.remove("usageDescription"); + raw + }, + }, }; Self { authentication_mode: value @@ -270,6 +293,23 @@ impl Connection { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + match value + .get("kind") + .and_then(|candidate| candidate.as_str()) + .unwrap_or("") + { + "reference" => {} + "remote" => {} + "key" => {} + "anonymous" => {} + "oauth" => {} + "foundry" => {} + _ => {} + } + Ok(()) + } + /// Returns the `kind` discriminator string for this instance. pub fn kind_str(&self) -> &str { match &self.kind { @@ -279,6 +319,7 @@ impl Connection { ConnectionKind::Anonymous { .. } => "anonymous", ConnectionKind::OAuth { .. } => "oauth", ConnectionKind::Foundry { .. } => "foundry", + ConnectionKind::Unknown { kind_name, .. } => kind_name.as_str(), } } @@ -293,13 +334,13 @@ impl Connection { serde_json::Value::String(self.kind_str().to_string()), ); // Write base fields - if let Some(ref val) = self.authentication_mode { + if let Some(val) = self.authentication_mode.as_ref() { result.insert( "authenticationMode".to_string(), serde_json::Value::String(val.to_string()), ); } - if let Some(ref val) = self.usage_description { + if let Some(val) = self.usage_description.as_ref() { result.insert( "usageDescription".to_string(), serde_json::Value::String(val.clone()), @@ -382,7 +423,7 @@ impl Connection { serde_json::Value::String(token_url.clone()), ); } - if let Some(items) = scopes { + if let Some(items) = scopes.as_ref() { result.insert( "scopes".to_string(), serde_json::to_value(items).unwrap_or(serde_json::Value::Null), @@ -411,6 +452,17 @@ impl Connection { ); } } + ConnectionKind::Unknown { raw, .. } => { + for (key, value) in raw { + if matches!( + key.as_str(), + "kind" | "authenticationMode" | "usageDescription" + ) { + continue; + } + result.insert(key.clone(), value.clone()); + } + } } ctx.process_dict(serde_json::Value::Object(result)) } @@ -438,6 +490,7 @@ impl serde::Serialize for Connection { impl<'de> serde::Deserialize<'de> for Connection { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } @@ -459,6 +512,7 @@ impl serde::Serialize for ConnectionKind { impl<'de> serde::Deserialize<'de> for ConnectionKind { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Connection::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Connection::load_from_value(&value, &LoadContext::default()).kind) } } diff --git a/runtime/rust/prompty/src/model/connection/device_authorization.rs b/runtime/rust/prompty/src/model/connection/device_authorization.rs index 5a2c7a640..91e4c4e8a 100644 --- a/runtime/rust/prompty/src/model/connection/device_authorization.rs +++ b/runtime/rust/prompty/src/model/connection/device_authorization.rs @@ -37,12 +37,16 @@ impl DeviceAuthorization { /// Load DeviceAuthorization from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load DeviceAuthorization from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -51,6 +55,9 @@ impl DeviceAuthorization { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { device_code: value .get("deviceCode") @@ -77,6 +84,10 @@ impl DeviceAuthorization { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize DeviceAuthorization to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -188,6 +199,7 @@ impl serde::Serialize for DeviceAuthorization { impl<'de> serde::Deserialize<'de> for DeviceAuthorization { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/connection/o_auth_token.rs b/runtime/rust/prompty/src/model/connection/o_auth_token.rs index 0d27470c9..d3439f79a 100644 --- a/runtime/rust/prompty/src/model/connection/o_auth_token.rs +++ b/runtime/rust/prompty/src/model/connection/o_auth_token.rs @@ -35,12 +35,16 @@ impl OAuthToken { /// Load OAuthToken from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load OAuthToken from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -49,6 +53,9 @@ impl OAuthToken { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { access_token: value .get("accessToken") @@ -72,6 +79,10 @@ impl OAuthToken { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize OAuthToken to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -96,13 +107,13 @@ impl OAuthToken { serde_json::Value::Number(serde_json::Number::from(self.expires_in)), ); } - if let Some(ref val) = self.refresh_token { + if let Some(val) = self.refresh_token.as_ref() { result.insert( "refreshToken".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.scope { + if let Some(val) = self.scope.as_ref() { result.insert("scope".to_string(), serde_json::Value::String(val.clone())); } ctx.process_dict(serde_json::Value::Object(result)) @@ -170,6 +181,7 @@ impl serde::Serialize for OAuthToken { impl<'de> serde::Deserialize<'de> for OAuthToken { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/conversation/content_part.rs b/runtime/rust/prompty/src/model/conversation/content_part.rs index 6a4efc2dc..9b881c9fa 100644 --- a/runtime/rust/prompty/src/model/conversation/content_part.rs +++ b/runtime/rust/prompty/src/model/conversation/content_part.rs @@ -67,12 +67,16 @@ impl ContentPart { /// Load ContentPart from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ContentPart from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -81,6 +85,9 @@ impl ContentPart { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } let kind_str = value.get("kind").and_then(|v| v.as_str()).unwrap_or(""); let kind = match kind_str { "text" => ContentPartKind::TextPart { @@ -127,11 +134,44 @@ impl ContentPart { .and_then(|v| v.as_str()) .map(|s| s.to_string()), }, - _ => ContentPartKind::default(), + _ => panic!( + "Unknown ContentPart discriminator field 'kind' value: {}", + kind_str + ), }; Self { kind: kind } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Self::validate_discriminator(value)?; + match value + .get("kind") + .and_then(|candidate| candidate.as_str()) + .unwrap_or("") + { + "text" => {} + "image" => {} + "file" => {} + "audio" => {} + _ => {} + } + Ok(()) + } + + fn validate_discriminator(value: &serde_json::Value) -> Result<(), String> { + let discriminator = value + .get("kind") + .and_then(|candidate| candidate.as_str()) + .ok_or_else(|| "Missing ContentPart discriminator property: 'kind'".to_string())?; + match discriminator { + "text" | "image" | "file" | "audio" => Ok(()), + _ => Err(format!( + "Unknown ContentPart discriminator field 'kind' value: {}", + discriminator + )), + } + } + /// Returns the `kind` discriminator string for this instance. pub fn kind_str(&self) -> &str { match &self.kind { @@ -244,6 +284,7 @@ impl serde::Serialize for ContentPart { impl<'de> serde::Deserialize<'de> for ContentPart { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } @@ -265,6 +306,7 @@ impl serde::Serialize for ContentPartKind { impl<'de> serde::Deserialize<'de> for ContentPartKind { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + ContentPart::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(ContentPart::load_from_value(&value, &LoadContext::default()).kind) } } diff --git a/runtime/rust/prompty/src/model/conversation/message.rs b/runtime/rust/prompty/src/model/conversation/message.rs index 192c0eb2d..ec1861f96 100644 --- a/runtime/rust/prompty/src/model/conversation/message.rs +++ b/runtime/rust/prompty/src/model/conversation/message.rs @@ -103,7 +103,7 @@ pub struct Message { pub role: Role, /// The content parts of the message pub parts: Vec, - /// Optional metadata associated with the message + /// Optional metadata associated with the message. Values may be explicit null. pub metadata: serde_json::Value, } @@ -116,12 +116,16 @@ impl Message { /// Load Message from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load Message from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -130,6 +134,9 @@ impl Message { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { role: value .get("role") @@ -147,6 +154,24 @@ impl Message { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("parts") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "parts".to_string() + } else { + format!("{}.parts", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + ContentPart::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize Message to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -250,6 +275,7 @@ impl serde::Serialize for Message { impl<'de> serde::Deserialize<'de> for Message { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/conversation/thread_marker.rs b/runtime/rust/prompty/src/model/conversation/thread_marker.rs index 2367397bd..83c6c1d69 100644 --- a/runtime/rust/prompty/src/model/conversation/thread_marker.rs +++ b/runtime/rust/prompty/src/model/conversation/thread_marker.rs @@ -29,12 +29,16 @@ impl ThreadMarker { /// Load ThreadMarker from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ThreadMarker from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -43,6 +47,9 @@ impl ThreadMarker { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { name: value .get("name") @@ -57,6 +64,10 @@ impl ThreadMarker { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize ThreadMarker to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -101,6 +112,7 @@ impl serde::Serialize for ThreadMarker { impl<'de> serde::Deserialize<'de> for ThreadMarker { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/conversation/tool_call.rs b/runtime/rust/prompty/src/model/conversation/tool_call.rs index 7686eb332..d48824f69 100644 --- a/runtime/rust/prompty/src/model/conversation/tool_call.rs +++ b/runtime/rust/prompty/src/model/conversation/tool_call.rs @@ -31,12 +31,16 @@ impl ToolCall { /// Load ToolCall from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ToolCall from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl ToolCall { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { id: value .get("id") @@ -64,6 +71,10 @@ impl ToolCall { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize ToolCall to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -111,6 +122,7 @@ impl serde::Serialize for ToolCall { impl<'de> serde::Deserialize<'de> for ToolCall { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/conversation/tool_result.rs b/runtime/rust/prompty/src/model/conversation/tool_result.rs index a0e0a0432..10f2ba1fe 100644 --- a/runtime/rust/prompty/src/model/conversation/tool_result.rs +++ b/runtime/rust/prompty/src/model/conversation/tool_result.rs @@ -114,12 +114,16 @@ impl ToolResult { /// Load ToolResult from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ToolResult from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -128,6 +132,9 @@ impl ToolResult { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { parts: value .get("parts") @@ -149,6 +156,24 @@ impl ToolResult { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("parts") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "parts".to_string() + } else { + format!("{}.parts", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + ContentPart::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize ToolResult to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -158,28 +183,28 @@ impl ToolResult { if !self.parts.is_empty() { result.insert("parts".to_string(), Self::save_parts(&self.parts, ctx)); } - if let Some(ref val) = self.status { + if let Some(val) = self.status.as_ref() { result.insert( "status".to_string(), serde_json::Value::String(val.to_string()), ); } - if let Some(ref val) = self.error_kind { + if let Some(val) = self.error_kind.as_ref() { result.insert( "errorKind".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.error_message { + if let Some(val) = self.error_message.as_ref() { result.insert( "errorMessage".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(val) = self.duration_ms { + if let Some(val) = self.duration_ms.as_ref() { result.insert( "durationMs".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); @@ -245,6 +270,7 @@ impl serde::Serialize for ToolResult { impl<'de> serde::Deserialize<'de> for ToolResult { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/core/file_not_found_error.rs b/runtime/rust/prompty/src/model/core/file_not_found_error.rs index 7e3351494..0993cb688 100644 --- a/runtime/rust/prompty/src/model/core/file_not_found_error.rs +++ b/runtime/rust/prompty/src/model/core/file_not_found_error.rs @@ -29,12 +29,16 @@ impl FileNotFoundError { /// Load FileNotFoundError from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load FileNotFoundError from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -43,6 +47,9 @@ impl FileNotFoundError { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { message: value .get("message") @@ -57,6 +64,10 @@ impl FileNotFoundError { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize FileNotFoundError to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -101,6 +112,7 @@ impl serde::Serialize for FileNotFoundError { impl<'de> serde::Deserialize<'de> for FileNotFoundError { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/core/invoker_error.rs b/runtime/rust/prompty/src/model/core/invoker_error.rs index bb751870c..862ce2e02 100644 --- a/runtime/rust/prompty/src/model/core/invoker_error.rs +++ b/runtime/rust/prompty/src/model/core/invoker_error.rs @@ -31,12 +31,16 @@ impl InvokerError { /// Load InvokerError from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load InvokerError from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl InvokerError { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { message: value .get("message") @@ -64,6 +71,10 @@ impl InvokerError { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize InvokerError to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -114,6 +125,7 @@ impl serde::Serialize for InvokerError { impl<'de> serde::Deserialize<'de> for InvokerError { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/core/property.rs b/runtime/rust/prompty/src/model/core/property.rs index a3e4c5076..7ddf85cc3 100644 --- a/runtime/rust/prompty/src/model/core/property.rs +++ b/runtime/rust/prompty/src/model/core/property.rs @@ -17,7 +17,7 @@ pub enum PropertyKind { /// `kind` = `"array"` Array { /// The type of items contained in the array - items: serde_json::Value, + items: Option, }, /// `kind` = `"object"` Object { @@ -27,14 +27,16 @@ pub enum PropertyKind { /// `kind` = `"union"` Union { /// Alternative property schemas where exactly one branch must match - one_of: Vec, + one_of: Option>, /// Alternative property schemas where one or more branches may match - any_of: Vec, + any_of: Option>, }, /// Wildcard / catch-all variant for unrecognized `kind` values. Custom { /// The raw `kind` string for this unknown variant. kind_name: String, + /// Unmodeled fields preserved for forward-compatible round trips. + raw: serde_json::Map, }, } @@ -42,6 +44,7 @@ impl Default for PropertyKind { fn default() -> Self { PropertyKind::Custom { kind_name: String::new(), + raw: serde_json::Map::new(), } } } @@ -61,7 +64,7 @@ pub struct Property { /// Example value used for either initialization or tooling pub example: Option, /// Allowed enumeration values for the property - pub enum_values: Option>, + pub enum_values: Vec, /// Variant-specific data, discriminated by `kind`. pub kind: PropertyKind, } @@ -75,12 +78,16 @@ impl Property { /// Load Property from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load Property from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -89,10 +96,25 @@ impl Property { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } if let Some(value) = value.as_bool() { return Property { kind: PropertyKind::Custom { kind_name: "boolean".to_string(), + raw: serde_json::Map::new(), + }, + example: Some(value.into()), + ..Default::default() + }; + } + if let Some(s) = value.as_str() { + let value = s.to_string(); + return Property { + kind: PropertyKind::Custom { + kind_name: "string".to_string(), + raw: serde_json::Map::new(), }, example: Some(value.into()), ..Default::default() @@ -102,16 +124,17 @@ impl Property { return Property { kind: PropertyKind::Custom { kind_name: "integer".to_string(), + raw: serde_json::Map::new(), }, example: Some(value.into()), ..Default::default() }; } - if let Some(s) = value.as_str() { - let value = s.to_string(); + if let Some(value) = value.as_f64() { return Property { kind: PropertyKind::Custom { - kind_name: "string".to_string(), + kind_name: "float".to_string(), + raw: serde_json::Map::new(), }, example: Some(value.into()), ..Default::default() @@ -120,10 +143,7 @@ impl Property { let kind_str = value.get("kind").and_then(|v| v.as_str()).unwrap_or(""); let kind = match kind_str { "array" => PropertyKind::Array { - items: value - .get("items") - .cloned() - .unwrap_or(serde_json::Value::Null), + items: value.get("items").cloned(), }, "object" => PropertyKind::Object { properties: value @@ -132,17 +152,23 @@ impl Property { .unwrap_or_default(), }, "union" => PropertyKind::Union { - one_of: value - .get("oneOf") - .map(|v| Self::load_one_of(v, ctx)) - .unwrap_or_default(), - any_of: value - .get("anyOf") - .map(|v| Self::load_any_of(v, ctx)) - .unwrap_or_default(), + one_of: value.get("oneOf").map(|v| Self::load_one_of(v, ctx)), + any_of: value.get("anyOf").map(|v| Self::load_any_of(v, ctx)), }, _ => PropertyKind::Custom { kind_name: kind_str.to_string(), + raw: { + let mut raw = value.as_object().cloned().unwrap_or_default(); + raw.remove("name"); + raw.remove("kind"); + raw.remove("description"); + raw.remove("required"); + raw.remove("nullable"); + raw.remove("default"); + raw.remove("example"); + raw.remove("enumValues"); + raw + }, }, }; Self { @@ -162,11 +188,104 @@ impl Property { enum_values: value .get("enumValues") .and_then(|v| v.as_array()) - .map(|arr| arr.to_vec()), + .map(|arr| arr.to_vec()) + .unwrap_or_default(), kind: kind, } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + match value + .get("kind") + .and_then(|candidate| candidate.as_str()) + .unwrap_or("") + { + "array" => { + let child_path = if path.is_empty() { + "items".to_string() + } else { + format!("{}.items", path) + }; + if let Some(child) = value.get("items") { + Property::validate_input_at(child, &child_path)?; + } + } + "object" => { + if let Some(collection) = value.get("properties") { + let collection_path = if path.is_empty() { + "properties".to_string() + } else { + format!("{}.properties", path) + }; + match collection { + serde_json::Value::Object(entries) => { + for (name, entry) in entries { + let entry_path = format!("{}.{}", collection_path, name); + if entry.is_array() { + return Err(format!( + "{}: invalid named collection entry category array", + entry_path + )); + } + let mut candidate = if entry.is_object() { + entry.clone() + } else { + serde_json::json!({ "kind": entry }) + }; + if let serde_json::Value::Object(ref mut map) = candidate { + map.insert( + "name".to_string(), + serde_json::Value::String(name.clone()), + ); + } + Property::validate_input_at(&candidate, &entry_path)?; + } + } + serde_json::Value::Array(entries) => { + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Property::validate_input_at(entry, &entry_path)?; + } + } + _ => {} + } + } + } + "union" => { + if let Some(entries) = value + .get("oneOf") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "oneOf".to_string() + } else { + format!("{}.oneOf", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Property::validate_input_at(entry, &entry_path)?; + } + } + if let Some(entries) = value + .get("anyOf") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "anyOf".to_string() + } else { + format!("{}.anyOf", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Property::validate_input_at(entry, &entry_path)?; + } + } + } + _ => {} + } + Ok(()) + } + /// Returns the `kind` discriminator string for this instance. pub fn kind_str(&self) -> &str { match &self.kind { @@ -194,66 +313,77 @@ impl Property { serde_json::Value::String(self.name.clone()), ); } - if let Some(ref val) = self.description { + if let Some(val) = self.description.as_ref() { result.insert( "description".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(val) = self.required { - result.insert("required".to_string(), serde_json::Value::Bool(val)); + if let Some(val) = self.required.as_ref() { + result.insert("required".to_string(), serde_json::Value::Bool(*val)); } - if let Some(val) = self.nullable { - result.insert("nullable".to_string(), serde_json::Value::Bool(val)); + if let Some(val) = self.nullable.as_ref() { + result.insert("nullable".to_string(), serde_json::Value::Bool(*val)); } - if let Some(ref val) = self.default { + if let Some(val) = self.default.as_ref() { result.insert("default".to_string(), val.clone()); } - if let Some(ref val) = self.example { + if let Some(val) = self.example.as_ref() { result.insert("example".to_string(), val.clone()); } - if let Some(ref items) = self.enum_values { - result.insert( - "enumValues".to_string(), - serde_json::to_value(items).unwrap_or(serde_json::Value::Null), - ); - } + result.insert( + "enumValues".to_string(), + serde_json::to_value(&self.enum_values).unwrap_or(serde_json::Value::Null), + ); // Write variant-specific fields match &self.kind { PropertyKind::Array { items, .. } => { - if !items.is_null() { - result.insert("items".to_string(), items.clone()); + if let Some(val) = items { + result.insert("items".to_string(), val.clone()); } } PropertyKind::Object { properties, .. } => { - if !properties.is_empty() { - result.insert( - "properties".to_string(), - serde_json::Value::Array( - properties.iter().map(|item| item.to_value(ctx)).collect(), - ), - ); - } + result.insert( + "properties".to_string(), + Self::save_properties(properties, ctx), + ); } PropertyKind::Union { one_of, any_of, .. } => { - if !one_of.is_empty() { + if let Some(items) = one_of.as_ref() { result.insert( "oneOf".to_string(), serde_json::Value::Array( - one_of.iter().map(|item| item.to_value(ctx)).collect(), + items.iter().map(|item| item.to_value(ctx)).collect(), ), ); } - if !any_of.is_empty() { + if let Some(items) = any_of.as_ref() { result.insert( "anyOf".to_string(), serde_json::Value::Array( - any_of.iter().map(|item| item.to_value(ctx)).collect(), + items.iter().map(|item| item.to_value(ctx)).collect(), ), ); } } - PropertyKind::Custom { kind_name: _, .. } => {} + PropertyKind::Custom { raw, .. } => { + for (key, value) in raw { + if matches!( + key.as_str(), + "name" + | "kind" + | "description" + | "required" + | "nullable" + | "default" + | "example" + | "enumValues" + ) { + continue; + } + result.insert(key.clone(), value.clone()); + } + } } ctx.process_dict(serde_json::Value::Object(result)) } @@ -279,20 +409,31 @@ impl Property { serde_json::Value::Object(obj) => obj .iter() - .filter_map(|(name, value)| { + .map(|(name, value)| { if value.is_array() { - return None; + panic!( + "properties.{}: invalid named collection entry category array", + name + ); } let mut v = if value.is_object() { value.clone() + } else if value.is_i64() { + serde_json::json!({ "kind": "integer", "default": value }) + } else if value.is_f64() { + serde_json::json!({ "kind": "float", "default": value }) + } else if value.is_string() { + serde_json::json!({ "kind": "string", "default": value }) + } else if value.is_boolean() { + serde_json::json!({ "kind": "boolean", "default": value }) } else { - serde_json::json!({ "kind": value }) + serde_json::json!({ "default": value }) }; if let serde_json::Value::Object(ref mut m) = v { m.entry("name".to_string()) .or_insert_with(|| serde_json::Value::String(name.clone())); } - Some(Property::load_from_value(&v, ctx)) + Property::load_from_value(&v, ctx) }) .collect(), _ => Vec::new(), @@ -301,18 +442,35 @@ impl Property { /// Save a collection of Property to a JSON value. fn save_properties(items: &[Property], ctx: &SaveContext) -> serde_json::Value { + let mut serialized = items + .iter() + .map(|item| item.to_value(ctx)) + .collect::>(); + for item_data in &mut serialized { + if let serde_json::Value::Object(map) = item_data { + if matches!(map.get("name"), Some(serde_json::Value::String(name)) if name.is_empty()) + { + map.remove("name"); + } + } + } + if ctx.collection_format == "array" { - return serde_json::Value::Array( - items - .iter() - .map(|item| item.to_value(ctx)) - .collect::>(), - ); + return serde_json::Value::Array(serialized); + } + let mut names = std::collections::HashSet::new(); + for item_data in &serialized { + let Some(name) = item_data.get("name").and_then(|value| value.as_str()) else { + return serde_json::Value::Array(serialized); + }; + if name.is_empty() || !names.insert(name.to_string()) { + return serde_json::Value::Array(serialized); + } } // Object format: use name as key let mut result = serde_json::Map::new(); - for item in items { - let mut item_data = match item.to_value(ctx) { + for item_data in serialized { + let mut item_data = match item_data { serde_json::Value::Object(m) => m, other => { let mut m = serde_json::Map::new(); @@ -320,9 +478,19 @@ impl Property { m } }; - if let Some(serde_json::Value::String(name)) = item_data.remove("name") { - result.insert(name, serde_json::Value::Object(item_data)); + let serde_json::Value::String(name) = item_data + .remove("name") + .expect("validated named collection item") + else { + unreachable!() + }; + if ctx.use_shorthand && item_data.len() == 1 { + if let Some(shorthand) = item_data.get("example") { + result.insert(name, shorthand.clone()); + continue; + } } + result.insert(name, serde_json::Value::Object(item_data)); } serde_json::Value::Object(result) } @@ -386,6 +554,7 @@ impl serde::Serialize for Property { impl<'de> serde::Deserialize<'de> for Property { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } @@ -407,6 +576,7 @@ impl serde::Serialize for PropertyKind { impl<'de> serde::Deserialize<'de> for PropertyKind { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Property::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Property::load_from_value(&value, &LoadContext::default()).kind) } } diff --git a/runtime/rust/prompty/src/model/core/validation_error.rs b/runtime/rust/prompty/src/model/core/validation_error.rs index b93f50f79..db70c09d0 100644 --- a/runtime/rust/prompty/src/model/core/validation_error.rs +++ b/runtime/rust/prompty/src/model/core/validation_error.rs @@ -31,12 +31,16 @@ impl ValidationError { /// Load ValidationError from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ValidationError from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl ValidationError { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { message: value .get("message") @@ -64,6 +71,10 @@ impl ValidationError { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize ValidationError to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -114,6 +125,7 @@ impl serde::Serialize for ValidationError { impl<'de> serde::Deserialize<'de> for ValidationError { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/core/validation_result.rs b/runtime/rust/prompty/src/model/core/validation_result.rs index 3be967936..8d29a0bd1 100644 --- a/runtime/rust/prompty/src/model/core/validation_result.rs +++ b/runtime/rust/prompty/src/model/core/validation_result.rs @@ -31,12 +31,16 @@ impl ValidationResult { /// Load ValidationResult from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ValidationResult from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl ValidationResult { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { valid: value .get("valid") @@ -57,6 +64,24 @@ impl ValidationResult { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("errors") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "errors".to_string() + } else { + format!("{}.errors", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + ValidationError::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize ValidationResult to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -116,6 +141,7 @@ impl serde::Serialize for ValidationResult { impl<'de> serde::Deserialize<'de> for ValidationResult { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/checkpoint.rs b/runtime/rust/prompty/src/model/events/checkpoint.rs index 80fced845..1e5f5d342 100644 --- a/runtime/rust/prompty/src/model/events/checkpoint.rs +++ b/runtime/rust/prompty/src/model/events/checkpoint.rs @@ -49,12 +49,16 @@ impl Checkpoint { /// Load Checkpoint from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load Checkpoint from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -63,6 +67,9 @@ impl Checkpoint { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { id: value .get("id") @@ -112,28 +119,40 @@ impl Checkpoint { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "redaction".to_string() + } else { + format!("{}.redaction", path) + }; + if let Some(child) = value.get("redaction") { + RedactionMetadata::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize Checkpoint to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.id { + if let Some(val) = self.id.as_ref() { result.insert("id".to_string(), serde_json::Value::String(val.clone())); } - if let Some(ref val) = self.session_id { + if let Some(val) = self.session_id.as_ref() { result.insert( "sessionId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.turn_id { + if let Some(val) = self.turn_id.as_ref() { result.insert("turnId".to_string(), serde_json::Value::String(val.clone())); } - if let Some(val) = self.checkpoint_number { + if let Some(val) = self.checkpoint_number.as_ref() { result.insert( "checkpointNumber".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } if !self.title.is_empty() { @@ -142,7 +161,7 @@ impl Checkpoint { serde_json::Value::String(self.title.clone()), ); } - if let Some(ref val) = self.overview { + if let Some(val) = self.overview.as_ref() { result.insert( "overview".to_string(), serde_json::Value::String(val.clone()), @@ -151,7 +170,7 @@ impl Checkpoint { if !self.state.is_null() { result.insert("state".to_string(), self.state.clone()); } - if let Some(ref val) = self.summary { + if let Some(val) = self.summary.as_ref() { result.insert( "summary".to_string(), serde_json::Value::String(val.clone()), @@ -160,13 +179,13 @@ impl Checkpoint { if !self.metadata.is_null() { result.insert("metadata".to_string(), self.metadata.clone()); } - if let Some(ref val) = self.created_at { + if let Some(val) = self.created_at.as_ref() { result.insert( "createdAt".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.redaction { + if let Some(val) = self.redaction.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("redaction".to_string(), nested); @@ -209,6 +228,7 @@ impl serde::Serialize for Checkpoint { impl<'de> serde::Deserialize<'de> for Checkpoint { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/compaction_complete_payload.rs b/runtime/rust/prompty/src/model/events/compaction_complete_payload.rs index 4d9ff3b3e..ac3b237ed 100644 --- a/runtime/rust/prompty/src/model/events/compaction_complete_payload.rs +++ b/runtime/rust/prompty/src/model/events/compaction_complete_payload.rs @@ -31,12 +31,16 @@ impl CompactionCompletePayload { /// Load CompactionCompletePayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load CompactionCompletePayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl CompactionCompletePayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { removed: value.get("removed").and_then(|v| v.as_i64()).unwrap_or(0) as i32, remaining: value.get("remaining").and_then(|v| v.as_i64()).unwrap_or(0) as i32, @@ -55,6 +62,10 @@ impl CompactionCompletePayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize CompactionCompletePayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -73,10 +84,10 @@ impl CompactionCompletePayload { serde_json::Value::Number(serde_json::Number::from(self.remaining)), ); } - if let Some(val) = self.summary_length { + if let Some(val) = self.summary_length.as_ref() { result.insert( "summaryLength".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } ctx.process_dict(serde_json::Value::Object(result)) @@ -105,6 +116,7 @@ impl serde::Serialize for CompactionCompletePayload { impl<'de> serde::Deserialize<'de> for CompactionCompletePayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/compaction_failed_payload.rs b/runtime/rust/prompty/src/model/events/compaction_failed_payload.rs index a5a5b15af..d04c79bbd 100644 --- a/runtime/rust/prompty/src/model/events/compaction_failed_payload.rs +++ b/runtime/rust/prompty/src/model/events/compaction_failed_payload.rs @@ -27,12 +27,16 @@ impl CompactionFailedPayload { /// Load CompactionFailedPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load CompactionFailedPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -41,6 +45,9 @@ impl CompactionFailedPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { message: value .get("message") @@ -50,6 +57,10 @@ impl CompactionFailedPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize CompactionFailedPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -88,6 +99,7 @@ impl serde::Serialize for CompactionFailedPayload { impl<'de> serde::Deserialize<'de> for CompactionFailedPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/compaction_start_payload.rs b/runtime/rust/prompty/src/model/events/compaction_start_payload.rs index a8c99e88f..4f7e33de6 100644 --- a/runtime/rust/prompty/src/model/events/compaction_start_payload.rs +++ b/runtime/rust/prompty/src/model/events/compaction_start_payload.rs @@ -27,12 +27,16 @@ impl CompactionStartPayload { /// Load CompactionStartPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load CompactionStartPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -41,6 +45,9 @@ impl CompactionStartPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { dropped_count: value .get("droppedCount") @@ -49,6 +56,10 @@ impl CompactionStartPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize CompactionStartPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -87,6 +98,7 @@ impl serde::Serialize for CompactionStartPayload { impl<'de> serde::Deserialize<'de> for CompactionStartPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/done_event_payload.rs b/runtime/rust/prompty/src/model/events/done_event_payload.rs index 157c86785..444f155eb 100644 --- a/runtime/rust/prompty/src/model/events/done_event_payload.rs +++ b/runtime/rust/prompty/src/model/events/done_event_payload.rs @@ -31,12 +31,16 @@ impl DoneEventPayload { /// Load DoneEventPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load DoneEventPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl DoneEventPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { response: value .get("response") @@ -57,6 +64,24 @@ impl DoneEventPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("messages") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "messages".to_string() + } else { + format!("{}.messages", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Message::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize DoneEventPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -121,6 +146,7 @@ impl serde::Serialize for DoneEventPayload { impl<'de> serde::Deserialize<'de> for DoneEventPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/error_event_payload.rs b/runtime/rust/prompty/src/model/events/error_event_payload.rs index 332ccdc66..a50fce320 100644 --- a/runtime/rust/prompty/src/model/events/error_event_payload.rs +++ b/runtime/rust/prompty/src/model/events/error_event_payload.rs @@ -31,12 +31,16 @@ impl ErrorEventPayload { /// Load ErrorEventPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ErrorEventPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl ErrorEventPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { message: value .get("message") @@ -62,6 +69,10 @@ impl ErrorEventPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize ErrorEventPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -74,13 +85,13 @@ impl ErrorEventPayload { serde_json::Value::String(self.message.clone()), ); } - if let Some(ref val) = self.error_kind { + if let Some(val) = self.error_kind.as_ref() { result.insert( "errorKind".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.phase { + if let Some(val) = self.phase.as_ref() { result.insert("phase".to_string(), serde_json::Value::String(val.clone())); } ctx.process_dict(serde_json::Value::Object(result)) @@ -109,6 +120,7 @@ impl serde::Serialize for ErrorEventPayload { impl<'de> serde::Deserialize<'de> for ErrorEventPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/harness_context.rs b/runtime/rust/prompty/src/model/events/harness_context.rs index ffc6b326e..6df41c22f 100644 --- a/runtime/rust/prompty/src/model/events/harness_context.rs +++ b/runtime/rust/prompty/src/model/events/harness_context.rs @@ -31,12 +31,16 @@ impl HarnessContext { /// Load HarnessContext from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load HarnessContext from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl HarnessContext { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { cwd: value .get("cwd") @@ -61,16 +68,20 @@ impl HarnessContext { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize HarnessContext to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.cwd { + if let Some(val) = self.cwd.as_ref() { result.insert("cwd".to_string(), serde_json::Value::String(val.clone())); } - if let Some(ref val) = self.git_root { + if let Some(val) = self.git_root.as_ref() { result.insert( "gitRoot".to_string(), serde_json::Value::String(val.clone()), @@ -110,6 +121,7 @@ impl serde::Serialize for HarnessContext { impl<'de> serde::Deserialize<'de> for HarnessContext { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/hook_end_payload.rs b/runtime/rust/prompty/src/model/events/hook_end_payload.rs index 0f2685d35..dc2f2ca99 100644 --- a/runtime/rust/prompty/src/model/events/hook_end_payload.rs +++ b/runtime/rust/prompty/src/model/events/hook_end_payload.rs @@ -105,12 +105,16 @@ impl HookEndPayload { /// Load HookEndPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load HookEndPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -119,6 +123,9 @@ impl HookEndPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { hook_invocation_id: value .get("hookInvocationId") @@ -154,6 +161,18 @@ impl HookEndPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "redaction".to_string() + } else { + format!("{}.redaction", path) + }; + if let Some(child) = value.get("redaction") { + RedactionMetadata::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize HookEndPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -172,7 +191,7 @@ impl HookEndPayload { serde_json::Value::String(self.hook_type.clone()), ); } - if let Some(ref val) = self.scope { + if let Some(val) = self.scope.as_ref() { result.insert( "scope".to_string(), serde_json::Value::String(val.to_string()), @@ -182,18 +201,18 @@ impl HookEndPayload { if !self.output.is_null() { result.insert("output".to_string(), self.output.clone()); } - if let Some(val) = self.duration_ms { + if let Some(val) = self.duration_ms.as_ref() { result.insert( "durationMs".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); } - if let Some(ref val) = self.error { + if let Some(val) = self.error.as_ref() { result.insert("error".to_string(), serde_json::Value::String(val.clone())); } - if let Some(ref val) = self.redaction { + if let Some(val) = self.redaction.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("redaction".to_string(), nested); @@ -230,6 +249,7 @@ impl serde::Serialize for HookEndPayload { impl<'de> serde::Deserialize<'de> for HookEndPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/hook_start_payload.rs b/runtime/rust/prompty/src/model/events/hook_start_payload.rs index a535d798a..5b525e3b4 100644 --- a/runtime/rust/prompty/src/model/events/hook_start_payload.rs +++ b/runtime/rust/prompty/src/model/events/hook_start_payload.rs @@ -99,12 +99,16 @@ impl HookStartPayload { /// Load HookStartPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load HookStartPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -113,6 +117,9 @@ impl HookStartPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { hook_invocation_id: value .get("hookInvocationId") @@ -139,6 +146,18 @@ impl HookStartPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "redaction".to_string() + } else { + format!("{}.redaction", path) + }; + if let Some(child) = value.get("redaction") { + RedactionMetadata::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize HookStartPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -157,7 +176,7 @@ impl HookStartPayload { serde_json::Value::String(self.hook_type.clone()), ); } - if let Some(ref val) = self.scope { + if let Some(val) = self.scope.as_ref() { result.insert( "scope".to_string(), serde_json::Value::String(val.to_string()), @@ -166,7 +185,7 @@ impl HookStartPayload { if !self.input.is_null() { result.insert("input".to_string(), self.input.clone()); } - if let Some(ref val) = self.redaction { + if let Some(val) = self.redaction.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("redaction".to_string(), nested); @@ -203,6 +222,7 @@ impl serde::Serialize for HookStartPayload { impl<'de> serde::Deserialize<'de> for HookStartPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/host_tool_request.rs b/runtime/rust/prompty/src/model/events/host_tool_request.rs index e2f881220..8e33ff795 100644 --- a/runtime/rust/prompty/src/model/events/host_tool_request.rs +++ b/runtime/rust/prompty/src/model/events/host_tool_request.rs @@ -20,7 +20,7 @@ pub struct HostToolRequest { pub tool_call_id: Option, /// Name of the host tool being executed pub tool_name: String, - /// Tool arguments after host-side sanitization + /// Tool arguments after host-side sanitization. Values may be explicit null. pub arguments: serde_json::Value, /// Working directory or execution scope for the tool pub working_directory: Option, @@ -35,12 +35,16 @@ impl HostToolRequest { /// Load HostToolRequest from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load HostToolRequest from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -49,6 +53,9 @@ impl HostToolRequest { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { request_id: value .get("requestId") @@ -74,19 +81,23 @@ impl HostToolRequest { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize HostToolRequest to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.request_id { + if let Some(val) = self.request_id.as_ref() { result.insert( "requestId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.tool_call_id { + if let Some(val) = self.tool_call_id.as_ref() { result.insert( "toolCallId".to_string(), serde_json::Value::String(val.clone()), @@ -101,7 +112,7 @@ impl HostToolRequest { if !self.arguments.is_null() { result.insert("arguments".to_string(), self.arguments.clone()); } - if let Some(ref val) = self.working_directory { + if let Some(val) = self.working_directory.as_ref() { result.insert( "workingDirectory".to_string(), serde_json::Value::String(val.clone()), @@ -138,6 +149,7 @@ impl serde::Serialize for HostToolRequest { impl<'de> serde::Deserialize<'de> for HostToolRequest { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/host_tool_result.rs b/runtime/rust/prompty/src/model/events/host_tool_result.rs index 1967330b1..a78e1a77d 100644 --- a/runtime/rust/prompty/src/model/events/host_tool_result.rs +++ b/runtime/rust/prompty/src/model/events/host_tool_result.rs @@ -43,12 +43,16 @@ impl HostToolResult { /// Load HostToolResult from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load HostToolResult from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -57,6 +61,9 @@ impl HostToolResult { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { request_id: value .get("requestId") @@ -92,19 +99,23 @@ impl HostToolResult { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize HostToolResult to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.request_id { + if let Some(val) = self.request_id.as_ref() { result.insert( "requestId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.tool_call_id { + if let Some(val) = self.tool_call_id.as_ref() { result.insert( "toolCallId".to_string(), serde_json::Value::String(val.clone()), @@ -117,24 +128,24 @@ impl HostToolResult { ); } result.insert("success".to_string(), serde_json::Value::Bool(self.success)); - if let Some(ref val) = self.result { + if let Some(val) = self.result.as_ref() { result.insert("result".to_string(), val.clone()); } - if let Some(val) = self.exit_code { + if let Some(val) = self.exit_code.as_ref() { result.insert( "exitCode".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.duration_ms { + if let Some(val) = self.duration_ms.as_ref() { result.insert( "durationMs".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); } - if let Some(ref val) = self.error_kind { + if let Some(val) = self.error_kind.as_ref() { result.insert( "errorKind".to_string(), serde_json::Value::String(val.clone()), @@ -174,6 +185,7 @@ impl serde::Serialize for HostToolResult { impl<'de> serde::Deserialize<'de> for HostToolResult { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/llm_complete_payload.rs b/runtime/rust/prompty/src/model/events/llm_complete_payload.rs index 851856a49..b310083c0 100644 --- a/runtime/rust/prompty/src/model/events/llm_complete_payload.rs +++ b/runtime/rust/prompty/src/model/events/llm_complete_payload.rs @@ -35,12 +35,16 @@ impl LlmCompletePayload { /// Load LlmCompletePayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load LlmCompletePayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -49,6 +53,9 @@ impl LlmCompletePayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { request_id: value .get("requestId") @@ -66,34 +73,46 @@ impl LlmCompletePayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "usage".to_string() + } else { + format!("{}.usage", path) + }; + if let Some(child) = value.get("usage") { + TokenUsage::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize LlmCompletePayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.request_id { + if let Some(val) = self.request_id.as_ref() { result.insert( "requestId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.service_request_id { + if let Some(val) = self.service_request_id.as_ref() { result.insert( "serviceRequestId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.usage { + if let Some(val) = self.usage.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("usage".to_string(), nested); } } - if let Some(val) = self.duration_ms { + if let Some(val) = self.duration_ms.as_ref() { result.insert( "durationMs".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); @@ -124,6 +143,7 @@ impl serde::Serialize for LlmCompletePayload { impl<'de> serde::Deserialize<'de> for LlmCompletePayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/llm_start_payload.rs b/runtime/rust/prompty/src/model/events/llm_start_payload.rs index 7455f9271..64ff299cb 100644 --- a/runtime/rust/prompty/src/model/events/llm_start_payload.rs +++ b/runtime/rust/prompty/src/model/events/llm_start_payload.rs @@ -33,12 +33,16 @@ impl LlmStartPayload { /// Load LlmStartPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load LlmStartPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -47,6 +51,9 @@ impl LlmStartPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { provider: value .get("provider") @@ -67,34 +74,38 @@ impl LlmStartPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize LlmStartPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.provider { + if let Some(val) = self.provider.as_ref() { result.insert( "provider".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.model_id { + if let Some(val) = self.model_id.as_ref() { result.insert( "modelId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(val) = self.message_count { + if let Some(val) = self.message_count.as_ref() { result.insert( "messageCount".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.attempt { + if let Some(val) = self.attempt.as_ref() { result.insert( "attempt".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } ctx.process_dict(serde_json::Value::Object(result)) @@ -123,6 +134,7 @@ impl serde::Serialize for LlmStartPayload { impl<'de> serde::Deserialize<'de> for LlmStartPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/messages_updated_payload.rs b/runtime/rust/prompty/src/model/events/messages_updated_payload.rs index 98011c5a2..5b70433ff 100644 --- a/runtime/rust/prompty/src/model/events/messages_updated_payload.rs +++ b/runtime/rust/prompty/src/model/events/messages_updated_payload.rs @@ -17,11 +17,11 @@ use super::super::conversation::message::Message; #[derive(Debug, Clone, Default, PartialEq)] pub struct MessagesUpdatedPayload { /// The current full message list after the update - pub messages: Vec, + pub messages: Option>, /// Why the message list changed pub reason: Option, /// Messages appended by this update, when available - pub appended: Vec, + pub appended: Option>, /// Number of messages removed by this update, when available pub removed: Option, } @@ -35,12 +35,16 @@ impl MessagesUpdatedPayload { /// Load MessagesUpdatedPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load MessagesUpdatedPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -49,19 +53,16 @@ impl MessagesUpdatedPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { - messages: value - .get("messages") - .map(|v| Self::load_messages(v, ctx)) - .unwrap_or_default(), + messages: value.get("messages").map(|v| Self::load_messages(v, ctx)), reason: value .get("reason") .and_then(|v| v.as_str()) .map(|s| s.to_string()), - appended: value - .get("appended") - .map(|v| Self::load_appended(v, ctx)) - .unwrap_or_default(), + appended: value.get("appended").map(|v| Self::load_appended(v, ctx)), removed: value .get("removed") .and_then(|v| v.as_i64()) @@ -69,31 +70,57 @@ impl MessagesUpdatedPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("messages") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "messages".to_string() + } else { + format!("{}.messages", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Message::validate_input_at(entry, &entry_path)?; + } + } + if let Some(entries) = value + .get("appended") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "appended".to_string() + } else { + format!("{}.appended", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Message::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize MessagesUpdatedPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if !self.messages.is_empty() { - result.insert( - "messages".to_string(), - Self::save_messages(&self.messages, ctx), - ); + if let Some(items) = self.messages.as_ref() { + result.insert("messages".to_string(), Self::save_messages(items, ctx)); } - if let Some(ref val) = self.reason { + if let Some(val) = self.reason.as_ref() { result.insert("reason".to_string(), serde_json::Value::String(val.clone())); } - if !self.appended.is_empty() { - result.insert( - "appended".to_string(), - Self::save_appended(&self.appended, ctx), - ); + if let Some(items) = self.appended.as_ref() { + result.insert("appended".to_string(), Self::save_appended(items, ctx)); } - if let Some(val) = self.removed { + if let Some(val) = self.removed.as_ref() { result.insert( "removed".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } ctx.process_dict(serde_json::Value::Object(result)) @@ -168,6 +195,7 @@ impl serde::Serialize for MessagesUpdatedPayload { impl<'de> serde::Deserialize<'de> for MessagesUpdatedPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/permission_completed_payload.rs b/runtime/rust/prompty/src/model/events/permission_completed_payload.rs index ba22c44a3..f758b4664 100644 --- a/runtime/rust/prompty/src/model/events/permission_completed_payload.rs +++ b/runtime/rust/prompty/src/model/events/permission_completed_payload.rs @@ -41,12 +41,16 @@ impl PermissionCompletedPayload { /// Load PermissionCompletedPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load PermissionCompletedPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -55,6 +59,9 @@ impl PermissionCompletedPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { request_id: value .get("requestId") @@ -88,19 +95,31 @@ impl PermissionCompletedPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "redaction".to_string() + } else { + format!("{}.redaction", path) + }; + if let Some(child) = value.get("redaction") { + RedactionMetadata::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize PermissionCompletedPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.request_id { + if let Some(val) = self.request_id.as_ref() { result.insert( "requestId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.tool_call_id { + if let Some(val) = self.tool_call_id.as_ref() { result.insert( "toolCallId".to_string(), serde_json::Value::String(val.clone()), @@ -116,13 +135,13 @@ impl PermissionCompletedPayload { "approved".to_string(), serde_json::Value::Bool(self.approved), ); - if let Some(ref val) = self.reason { + if let Some(val) = self.reason.as_ref() { result.insert("reason".to_string(), serde_json::Value::String(val.clone())); } if !self.result.is_null() { result.insert("result".to_string(), self.result.clone()); } - if let Some(ref val) = self.redaction { + if let Some(val) = self.redaction.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("redaction".to_string(), nested); @@ -159,6 +178,7 @@ impl serde::Serialize for PermissionCompletedPayload { impl<'de> serde::Deserialize<'de> for PermissionCompletedPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/permission_decision.rs b/runtime/rust/prompty/src/model/events/permission_decision.rs index c4f3017d0..c35e701f2 100644 --- a/runtime/rust/prompty/src/model/events/permission_decision.rs +++ b/runtime/rust/prompty/src/model/events/permission_decision.rs @@ -37,12 +37,16 @@ impl PermissionDecision { /// Load PermissionDecision from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load PermissionDecision from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -51,6 +55,9 @@ impl PermissionDecision { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { request_id: value .get("requestId") @@ -80,19 +87,23 @@ impl PermissionDecision { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize PermissionDecision to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.request_id { + if let Some(val) = self.request_id.as_ref() { result.insert( "requestId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.tool_call_id { + if let Some(val) = self.tool_call_id.as_ref() { result.insert( "toolCallId".to_string(), serde_json::Value::String(val.clone()), @@ -108,7 +119,7 @@ impl PermissionDecision { "approved".to_string(), serde_json::Value::Bool(self.approved), ); - if let Some(ref val) = self.reason { + if let Some(val) = self.reason.as_ref() { result.insert("reason".to_string(), serde_json::Value::String(val.clone())); } if !self.result.is_null() { @@ -145,6 +156,7 @@ impl serde::Serialize for PermissionDecision { impl<'de> serde::Deserialize<'de> for PermissionDecision { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/permission_request.rs b/runtime/rust/prompty/src/model/events/permission_request.rs index 2ed071b72..e8b2a2e18 100644 --- a/runtime/rust/prompty/src/model/events/permission_request.rs +++ b/runtime/rust/prompty/src/model/events/permission_request.rs @@ -39,12 +39,16 @@ impl PermissionRequest { /// Load PermissionRequest from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load PermissionRequest from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -53,6 +57,9 @@ impl PermissionRequest { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { request_id: value .get("requestId") @@ -86,19 +93,23 @@ impl PermissionRequest { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize PermissionRequest to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.request_id { + if let Some(val) = self.request_id.as_ref() { result.insert( "requestId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.tool_call_id { + if let Some(val) = self.tool_call_id.as_ref() { result.insert( "toolCallId".to_string(), serde_json::Value::String(val.clone()), @@ -110,13 +121,13 @@ impl PermissionRequest { serde_json::Value::String(self.permission.clone()), ); } - if let Some(ref val) = self.target { + if let Some(val) = self.target.as_ref() { result.insert("target".to_string(), serde_json::Value::String(val.clone())); } if !self.details.is_null() { result.insert("details".to_string(), self.details.clone()); } - if let Some(ref val) = self.prompt_request { + if let Some(val) = self.prompt_request.as_ref() { result.insert( "promptRequest".to_string(), serde_json::Value::String(val.clone()), @@ -162,6 +173,7 @@ impl serde::Serialize for PermissionRequest { impl<'de> serde::Deserialize<'de> for PermissionRequest { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/permission_requested_payload.rs b/runtime/rust/prompty/src/model/events/permission_requested_payload.rs index f63d352d8..5b186f707 100644 --- a/runtime/rust/prompty/src/model/events/permission_requested_payload.rs +++ b/runtime/rust/prompty/src/model/events/permission_requested_payload.rs @@ -43,12 +43,16 @@ impl PermissionRequestedPayload { /// Load PermissionRequestedPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load PermissionRequestedPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -57,6 +61,9 @@ impl PermissionRequestedPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { request_id: value .get("requestId") @@ -94,19 +101,31 @@ impl PermissionRequestedPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "redaction".to_string() + } else { + format!("{}.redaction", path) + }; + if let Some(child) = value.get("redaction") { + RedactionMetadata::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize PermissionRequestedPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.request_id { + if let Some(val) = self.request_id.as_ref() { result.insert( "requestId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.tool_call_id { + if let Some(val) = self.tool_call_id.as_ref() { result.insert( "toolCallId".to_string(), serde_json::Value::String(val.clone()), @@ -118,13 +137,13 @@ impl PermissionRequestedPayload { serde_json::Value::String(self.permission.clone()), ); } - if let Some(ref val) = self.target { + if let Some(val) = self.target.as_ref() { result.insert("target".to_string(), serde_json::Value::String(val.clone())); } if !self.details.is_null() { result.insert("details".to_string(), self.details.clone()); } - if let Some(ref val) = self.prompt_request { + if let Some(val) = self.prompt_request.as_ref() { result.insert( "promptRequest".to_string(), serde_json::Value::String(val.clone()), @@ -133,7 +152,7 @@ impl PermissionRequestedPayload { if !self.policy.is_null() { result.insert("policy".to_string(), self.policy.clone()); } - if let Some(ref val) = self.redaction { + if let Some(val) = self.redaction.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("redaction".to_string(), nested); @@ -176,6 +195,7 @@ impl serde::Serialize for PermissionRequestedPayload { impl<'de> serde::Deserialize<'de> for PermissionRequestedPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/redacted_field.rs b/runtime/rust/prompty/src/model/events/redacted_field.rs index 6d977e74c..c2e42ec07 100644 --- a/runtime/rust/prompty/src/model/events/redacted_field.rs +++ b/runtime/rust/prompty/src/model/events/redacted_field.rs @@ -114,12 +114,16 @@ impl RedactedField { /// Load RedactedField from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load RedactedField from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -128,6 +132,9 @@ impl RedactedField { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { path: value .get("path") @@ -146,6 +153,10 @@ impl RedactedField { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize RedactedField to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -162,7 +173,7 @@ impl RedactedField { "mode".to_string(), serde_json::Value::String(self.mode.to_string()), ); - if let Some(ref val) = self.reason { + if let Some(val) = self.reason.as_ref() { result.insert("reason".to_string(), serde_json::Value::String(val.clone())); } ctx.process_dict(serde_json::Value::Object(result)) @@ -191,6 +202,7 @@ impl serde::Serialize for RedactedField { impl<'de> serde::Deserialize<'de> for RedactedField { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/redaction_metadata.rs b/runtime/rust/prompty/src/model/events/redaction_metadata.rs index e7f538474..3c4550621 100644 --- a/runtime/rust/prompty/src/model/events/redaction_metadata.rs +++ b/runtime/rust/prompty/src/model/events/redaction_metadata.rs @@ -19,7 +19,7 @@ pub struct RedactionMetadata { /// Whether the payload has been sanitized for persistence or external display pub sanitized: Option, /// Field-level redaction details - pub fields: Vec, + pub fields: Option>, /// Host policy or sanitizer version that produced this metadata pub policy: Option, } @@ -33,12 +33,16 @@ impl RedactionMetadata { /// Load RedactionMetadata from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load RedactionMetadata from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -47,12 +51,12 @@ impl RedactionMetadata { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { sanitized: value.get("sanitized").and_then(|v| v.as_bool()), - fields: value - .get("fields") - .map(|v| Self::load_fields(v, ctx)) - .unwrap_or_default(), + fields: value.get("fields").map(|v| Self::load_fields(v, ctx)), policy: value .get("policy") .and_then(|v| v.as_str()) @@ -60,19 +64,37 @@ impl RedactionMetadata { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("fields") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "fields".to_string() + } else { + format!("{}.fields", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + RedactedField::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize RedactionMetadata to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(val) = self.sanitized { - result.insert("sanitized".to_string(), serde_json::Value::Bool(val)); + if let Some(val) = self.sanitized.as_ref() { + result.insert("sanitized".to_string(), serde_json::Value::Bool(*val)); } - if !self.fields.is_empty() { - result.insert("fields".to_string(), Self::save_fields(&self.fields, ctx)); + if let Some(items) = self.fields.as_ref() { + result.insert("fields".to_string(), Self::save_fields(items, ctx)); } - if let Some(ref val) = self.policy { + if let Some(val) = self.policy.as_ref() { result.insert("policy".to_string(), serde_json::Value::String(val.clone())); } ctx.process_dict(serde_json::Value::Object(result)) @@ -124,6 +146,7 @@ impl serde::Serialize for RedactionMetadata { impl<'de> serde::Deserialize<'de> for RedactionMetadata { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/retry_payload.rs b/runtime/rust/prompty/src/model/events/retry_payload.rs index ca9305f95..e2f12ff00 100644 --- a/runtime/rust/prompty/src/model/events/retry_payload.rs +++ b/runtime/rust/prompty/src/model/events/retry_payload.rs @@ -35,12 +35,16 @@ impl RetryPayload { /// Load RetryPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load RetryPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -49,6 +53,9 @@ impl RetryPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { operation: value .get("operation") @@ -68,6 +75,10 @@ impl RetryPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize RetryPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -86,21 +97,21 @@ impl RetryPayload { serde_json::Value::Number(serde_json::Number::from(self.attempt)), ); } - if let Some(val) = self.max_attempts { + if let Some(val) = self.max_attempts.as_ref() { result.insert( "maxAttempts".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.delay_ms { + if let Some(val) = self.delay_ms.as_ref() { result.insert( "delayMs".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); } - if let Some(ref val) = self.reason { + if let Some(val) = self.reason.as_ref() { result.insert("reason".to_string(), serde_json::Value::String(val.clone())); } ctx.process_dict(serde_json::Value::Object(result)) @@ -129,6 +140,7 @@ impl serde::Serialize for RetryPayload { impl<'de> serde::Deserialize<'de> for RetryPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/session_end_payload.rs b/runtime/rust/prompty/src/model/events/session_end_payload.rs index 5e2fd46c1..7879523db 100644 --- a/runtime/rust/prompty/src/model/events/session_end_payload.rs +++ b/runtime/rust/prompty/src/model/events/session_end_payload.rs @@ -110,12 +110,16 @@ impl SessionEndPayload { /// Load SessionEndPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load SessionEndPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -124,6 +128,9 @@ impl SessionEndPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { session_id: value .get("sessionId") @@ -141,31 +148,35 @@ impl SessionEndPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize SessionEndPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.session_id { + if let Some(val) = self.session_id.as_ref() { result.insert( "sessionId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.status { + if let Some(val) = self.status.as_ref() { result.insert( "status".to_string(), serde_json::Value::String(val.to_string()), ); } - if let Some(ref val) = self.reason { + if let Some(val) = self.reason.as_ref() { result.insert("reason".to_string(), serde_json::Value::String(val.clone())); } - if let Some(val) = self.duration_ms { + if let Some(val) = self.duration_ms.as_ref() { result.insert( "durationMs".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); @@ -196,6 +207,7 @@ impl serde::Serialize for SessionEndPayload { impl<'de> serde::Deserialize<'de> for SessionEndPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/session_event.rs b/runtime/rust/prompty/src/model/events/session_event.rs index b050f8856..f5a048f32 100644 --- a/runtime/rust/prompty/src/model/events/session_event.rs +++ b/runtime/rust/prompty/src/model/events/session_event.rs @@ -128,7 +128,7 @@ pub struct SessionEvent { pub parent_id: Option, /// Trace span identifier associated with this event pub span_id: Option, - /// Event-specific payload. Use the typed payload model matching 'type'. + /// Event-specific payload. Values may be explicit null. Use the typed payload model matching 'type'. pub payload: serde_json::Value, /// Redaction state for sensitive payload fields pub redaction: Option, @@ -143,12 +143,16 @@ impl SessionEvent { /// Load SessionEvent from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load SessionEvent from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -157,6 +161,9 @@ impl SessionEvent { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { id: value .get("id") @@ -200,6 +207,18 @@ impl SessionEvent { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "redaction".to_string() + } else { + format!("{}.redaction", path) + }; + if let Some(child) = value.get("redaction") { + RedactionMetadata::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize SessionEvent to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -219,28 +238,28 @@ impl SessionEvent { serde_json::Value::String(self.timestamp.clone()), ); } - if let Some(ref val) = self.session_id { + if let Some(val) = self.session_id.as_ref() { result.insert( "sessionId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.turn_id { + if let Some(val) = self.turn_id.as_ref() { result.insert("turnId".to_string(), serde_json::Value::String(val.clone())); } - if let Some(ref val) = self.parent_id { + if let Some(val) = self.parent_id.as_ref() { result.insert( "parentId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.span_id { + if let Some(val) = self.span_id.as_ref() { result.insert("spanId".to_string(), serde_json::Value::String(val.clone())); } if !self.payload.is_null() { result.insert("payload".to_string(), self.payload.clone()); } - if let Some(ref val) = self.redaction { + if let Some(val) = self.redaction.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("redaction".to_string(), nested); @@ -277,6 +296,7 @@ impl serde::Serialize for SessionEvent { impl<'de> serde::Deserialize<'de> for SessionEvent { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/session_file_ref.rs b/runtime/rust/prompty/src/model/events/session_file_ref.rs index c2083bd02..fee44c3f2 100644 --- a/runtime/rust/prompty/src/model/events/session_file_ref.rs +++ b/runtime/rust/prompty/src/model/events/session_file_ref.rs @@ -35,12 +35,16 @@ impl SessionFileRef { /// Load SessionFileRef from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load SessionFileRef from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -49,6 +53,9 @@ impl SessionFileRef { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { session_id: value .get("sessionId") @@ -74,13 +81,17 @@ impl SessionFileRef { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize SessionFileRef to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.session_id { + if let Some(val) = self.session_id.as_ref() { result.insert( "sessionId".to_string(), serde_json::Value::String(val.clone()), @@ -92,19 +103,19 @@ impl SessionFileRef { serde_json::Value::String(self.path.clone()), ); } - if let Some(ref val) = self.tool_name { + if let Some(val) = self.tool_name.as_ref() { result.insert( "toolName".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(val) = self.turn_index { + if let Some(val) = self.turn_index.as_ref() { result.insert( "turnIndex".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(ref val) = self.first_seen_at { + if let Some(val) = self.first_seen_at.as_ref() { result.insert( "firstSeenAt".to_string(), serde_json::Value::String(val.clone()), @@ -136,6 +147,7 @@ impl serde::Serialize for SessionFileRef { impl<'de> serde::Deserialize<'de> for SessionFileRef { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/session_ref.rs b/runtime/rust/prompty/src/model/events/session_ref.rs index ce145eaba..0fdf08504 100644 --- a/runtime/rust/prompty/src/model/events/session_ref.rs +++ b/runtime/rust/prompty/src/model/events/session_ref.rs @@ -35,12 +35,16 @@ impl SessionRef { /// Load SessionRef from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load SessionRef from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -49,6 +53,9 @@ impl SessionRef { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { session_id: value .get("sessionId") @@ -75,13 +82,17 @@ impl SessionRef { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize SessionRef to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.session_id { + if let Some(val) = self.session_id.as_ref() { result.insert( "sessionId".to_string(), serde_json::Value::String(val.clone()), @@ -99,13 +110,13 @@ impl SessionRef { serde_json::Value::String(self.ref_value.clone()), ); } - if let Some(val) = self.turn_index { + if let Some(val) = self.turn_index.as_ref() { result.insert( "turnIndex".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(ref val) = self.created_at { + if let Some(val) = self.created_at.as_ref() { result.insert( "createdAt".to_string(), serde_json::Value::String(val.clone()), @@ -137,6 +148,7 @@ impl serde::Serialize for SessionRef { impl<'de> serde::Deserialize<'de> for SessionRef { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/session_start_payload.rs b/runtime/rust/prompty/src/model/events/session_start_payload.rs index 3c3c69473..5e357a4ac 100644 --- a/runtime/rust/prompty/src/model/events/session_start_payload.rs +++ b/runtime/rust/prompty/src/model/events/session_start_payload.rs @@ -45,12 +45,16 @@ impl SessionStartPayload { /// Load SessionStartPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load SessionStartPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -59,6 +63,9 @@ impl SessionStartPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { session_id: value .get("sessionId") @@ -100,6 +107,18 @@ impl SessionStartPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "context".to_string() + } else { + format!("{}.context", path) + }; + if let Some(child) = value.get("context") { + HarnessContext::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize SessionStartPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -112,49 +131,49 @@ impl SessionStartPayload { serde_json::Value::String(self.session_id.clone()), ); } - if let Some(ref val) = self.schema_version { + if let Some(val) = self.schema_version.as_ref() { result.insert( "schemaVersion".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.producer { + if let Some(val) = self.producer.as_ref() { result.insert( "producer".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.runtime { + if let Some(val) = self.runtime.as_ref() { result.insert( "runtime".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.prompty_version { + if let Some(val) = self.prompty_version.as_ref() { result.insert( "promptyVersion".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.start_time { + if let Some(val) = self.start_time.as_ref() { result.insert( "startTime".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.selected_model { + if let Some(val) = self.selected_model.as_ref() { result.insert( "selectedModel".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.reasoning_effort { + if let Some(val) = self.reasoning_effort.as_ref() { result.insert( "reasoningEffort".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.context { + if let Some(val) = self.context.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("context".to_string(), nested); @@ -186,6 +205,7 @@ impl serde::Serialize for SessionStartPayload { impl<'de> serde::Deserialize<'de> for SessionStartPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/session_summary.rs b/runtime/rust/prompty/src/model/events/session_summary.rs index 64abb82a9..6bef9cd87 100644 --- a/runtime/rust/prompty/src/model/events/session_summary.rs +++ b/runtime/rust/prompty/src/model/events/session_summary.rs @@ -116,12 +116,16 @@ impl SessionSummary { /// Load SessionSummary from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load SessionSummary from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -130,6 +134,9 @@ impl SessionSummary { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { session_id: value .get("sessionId") @@ -156,6 +163,18 @@ impl SessionSummary { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "usage".to_string() + } else { + format!("{}.usage", path) + }; + if let Some(child) = value.get("usage") { + TokenUsage::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize SessionSummary to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -168,34 +187,34 @@ impl SessionSummary { serde_json::Value::String(self.session_id.clone()), ); } - if let Some(ref val) = self.status { + if let Some(val) = self.status.as_ref() { result.insert( "status".to_string(), serde_json::Value::String(val.to_string()), ); } - if let Some(val) = self.turns { + if let Some(val) = self.turns.as_ref() { result.insert( "turns".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.checkpoints { + if let Some(val) = self.checkpoints.as_ref() { result.insert( "checkpoints".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(ref val) = self.usage { + if let Some(val) = self.usage.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("usage".to_string(), nested); } } - if let Some(val) = self.duration_ms { + if let Some(val) = self.duration_ms.as_ref() { result.insert( "durationMs".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); @@ -226,6 +245,7 @@ impl serde::Serialize for SessionSummary { impl<'de> serde::Deserialize<'de> for SessionSummary { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/session_trace.rs b/runtime/rust/prompty/src/model/events/session_trace.rs index ef2396411..c09b92e33 100644 --- a/runtime/rust/prompty/src/model/events/session_trace.rs +++ b/runtime/rust/prompty/src/model/events/session_trace.rs @@ -39,15 +39,15 @@ pub struct SessionTrace { /// Recorded session events in emission order pub events: Vec, /// Recorded turn traces associated with the session - pub turns: Vec, + pub turns: Option>, /// Checkpoints created during the session - pub checkpoints: Vec, + pub checkpoints: Option>, /// Compact trajectory records associated with the session - pub trajectory: Vec, + pub trajectory: Option>, /// Files observed or touched during the session - pub files: Vec, + pub files: Option>, /// Non-file references observed during the session - pub refs: Vec, + pub refs: Option>, /// Optional summary computed from the event stream pub summary: Option, } @@ -61,12 +61,16 @@ impl SessionTrace { /// Load SessionTrace from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load SessionTrace from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -75,6 +79,9 @@ impl SessionTrace { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { version: value .get("version") @@ -97,26 +104,15 @@ impl SessionTrace { .get("events") .map(|v| Self::load_events(v, ctx)) .unwrap_or_default(), - turns: value - .get("turns") - .map(|v| Self::load_turns(v, ctx)) - .unwrap_or_default(), + turns: value.get("turns").map(|v| Self::load_turns(v, ctx)), checkpoints: value .get("checkpoints") - .map(|v| Self::load_checkpoints(v, ctx)) - .unwrap_or_default(), + .map(|v| Self::load_checkpoints(v, ctx)), trajectory: value .get("trajectory") - .map(|v| Self::load_trajectory(v, ctx)) - .unwrap_or_default(), - files: value - .get("files") - .map(|v| Self::load_files(v, ctx)) - .unwrap_or_default(), - refs: value - .get("refs") - .map(|v| Self::load_refs(v, ctx)) - .unwrap_or_default(), + .map(|v| Self::load_trajectory(v, ctx)), + files: value.get("files").map(|v| Self::load_files(v, ctx)), + refs: value.get("refs").map(|v| Self::load_refs(v, ctx)), summary: value .get("summary") .filter(|v| v.is_object() || v.is_array() || v.is_string()) @@ -124,6 +120,99 @@ impl SessionTrace { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("events") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "events".to_string() + } else { + format!("{}.events", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + SessionEvent::validate_input_at(entry, &entry_path)?; + } + } + if let Some(entries) = value + .get("turns") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "turns".to_string() + } else { + format!("{}.turns", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + TurnTrace::validate_input_at(entry, &entry_path)?; + } + } + if let Some(entries) = value + .get("checkpoints") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "checkpoints".to_string() + } else { + format!("{}.checkpoints", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Checkpoint::validate_input_at(entry, &entry_path)?; + } + } + if let Some(entries) = value + .get("trajectory") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "trajectory".to_string() + } else { + format!("{}.trajectory", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + TrajectoryEvent::validate_input_at(entry, &entry_path)?; + } + } + if let Some(entries) = value + .get("files") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "files".to_string() + } else { + format!("{}.files", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + SessionFileRef::validate_input_at(entry, &entry_path)?; + } + } + if let Some(entries) = value.get("refs").and_then(|candidate| candidate.as_array()) { + let collection_path = if path.is_empty() { + "refs".to_string() + } else { + format!("{}.refs", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + SessionRef::validate_input_at(entry, &entry_path)?; + } + } + let child_path = if path.is_empty() { + "summary".to_string() + } else { + format!("{}.summary", path) + }; + if let Some(child) = value.get("summary") { + SessionSummary::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize SessionTrace to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -136,19 +225,19 @@ impl SessionTrace { serde_json::Value::String(self.version.clone()), ); } - if let Some(ref val) = self.runtime { + if let Some(val) = self.runtime.as_ref() { result.insert( "runtime".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.prompty_version { + if let Some(val) = self.prompty_version.as_ref() { result.insert( "promptyVersion".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.session_id { + if let Some(val) = self.session_id.as_ref() { result.insert( "sessionId".to_string(), serde_json::Value::String(val.clone()), @@ -157,28 +246,25 @@ impl SessionTrace { if !self.events.is_empty() { result.insert("events".to_string(), Self::save_events(&self.events, ctx)); } - if !self.turns.is_empty() { - result.insert("turns".to_string(), Self::save_turns(&self.turns, ctx)); + if let Some(items) = self.turns.as_ref() { + result.insert("turns".to_string(), Self::save_turns(items, ctx)); } - if !self.checkpoints.is_empty() { + if let Some(items) = self.checkpoints.as_ref() { result.insert( "checkpoints".to_string(), - Self::save_checkpoints(&self.checkpoints, ctx), + Self::save_checkpoints(items, ctx), ); } - if !self.trajectory.is_empty() { - result.insert( - "trajectory".to_string(), - Self::save_trajectory(&self.trajectory, ctx), - ); + if let Some(items) = self.trajectory.as_ref() { + result.insert("trajectory".to_string(), Self::save_trajectory(items, ctx)); } - if !self.files.is_empty() { - result.insert("files".to_string(), Self::save_files(&self.files, ctx)); + if let Some(items) = self.files.as_ref() { + result.insert("files".to_string(), Self::save_files(items, ctx)); } - if !self.refs.is_empty() { - result.insert("refs".to_string(), Self::save_refs(&self.refs, ctx)); + if let Some(items) = self.refs.as_ref() { + result.insert("refs".to_string(), Self::save_refs(items, ctx)); } - if let Some(ref val) = self.summary { + if let Some(val) = self.summary.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("summary".to_string(), nested); @@ -348,6 +434,7 @@ impl serde::Serialize for SessionTrace { impl<'de> serde::Deserialize<'de> for SessionTrace { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/session_warning_payload.rs b/runtime/rust/prompty/src/model/events/session_warning_payload.rs index 1f1a8001c..6714773ae 100644 --- a/runtime/rust/prompty/src/model/events/session_warning_payload.rs +++ b/runtime/rust/prompty/src/model/events/session_warning_payload.rs @@ -31,12 +31,16 @@ impl SessionWarningPayload { /// Load SessionWarningPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load SessionWarningPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl SessionWarningPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { warning_type: value .get("warningType") @@ -63,6 +70,10 @@ impl SessionWarningPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize SessionWarningPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -115,6 +126,7 @@ impl serde::Serialize for SessionWarningPayload { impl<'de> serde::Deserialize<'de> for SessionWarningPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/status_event_payload.rs b/runtime/rust/prompty/src/model/events/status_event_payload.rs index 20b527269..0ce906c53 100644 --- a/runtime/rust/prompty/src/model/events/status_event_payload.rs +++ b/runtime/rust/prompty/src/model/events/status_event_payload.rs @@ -27,12 +27,16 @@ impl StatusEventPayload { /// Load StatusEventPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load StatusEventPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -41,6 +45,9 @@ impl StatusEventPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { message: value .get("message") @@ -50,6 +57,10 @@ impl StatusEventPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize StatusEventPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -88,6 +99,7 @@ impl serde::Serialize for StatusEventPayload { impl<'de> serde::Deserialize<'de> for StatusEventPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/stream_chunk.rs b/runtime/rust/prompty/src/model/events/stream_chunk.rs index 30213cd13..8e32d0f35 100644 --- a/runtime/rust/prompty/src/model/events/stream_chunk.rs +++ b/runtime/rust/prompty/src/model/events/stream_chunk.rs @@ -68,12 +68,16 @@ impl StreamChunk { /// Load StreamChunk from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load StreamChunk from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -82,6 +86,9 @@ impl StreamChunk { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } let kind_str = value.get("kind").and_then(|v| v.as_str()).unwrap_or(""); let kind = match kind_str { "text" => StreamChunkKind::TextChunk { @@ -119,11 +126,67 @@ impl StreamChunk { .unwrap_or_default() .to_string(), }, - _ => StreamChunkKind::default(), + _ => panic!( + "Unknown StreamChunk discriminator field 'kind' value: {}", + kind_str + ), }; Self { kind: kind } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Self::validate_discriminator(value)?; + match value + .get("kind") + .and_then(|candidate| candidate.as_str()) + .unwrap_or("") + { + "text" => {} + "thinking" => {} + "tool" => { + let child_path = if path.is_empty() { + "toolCall".to_string() + } else { + format!("{}.toolCall", path) + }; + let child = value + .get("toolCall") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + ToolCall::validate_input_at(child, &child_path)?; + } + "usage" => { + let child_path = if path.is_empty() { + "usage".to_string() + } else { + format!("{}.usage", path) + }; + let child = value + .get("usage") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + InvocationUsage::validate_input_at(child, &child_path)?; + } + "error" => {} + _ => {} + } + Ok(()) + } + + fn validate_discriminator(value: &serde_json::Value) -> Result<(), String> { + let discriminator = value + .get("kind") + .and_then(|candidate| candidate.as_str()) + .ok_or_else(|| "Missing StreamChunk discriminator property: 'kind'".to_string())?; + match discriminator { + "text" | "thinking" | "tool" | "usage" | "error" => Ok(()), + _ => Err(format!( + "Unknown StreamChunk discriminator field 'kind' value: {}", + discriminator + )), + } + } + /// Returns the `kind` discriminator string for this instance. pub fn kind_str(&self) -> &str { match &self.kind { @@ -211,6 +274,7 @@ impl serde::Serialize for StreamChunk { impl<'de> serde::Deserialize<'de> for StreamChunk { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } @@ -232,6 +296,7 @@ impl serde::Serialize for StreamChunkKind { impl<'de> serde::Deserialize<'de> for StreamChunkKind { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + StreamChunk::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(StreamChunk::load_from_value(&value, &LoadContext::default()).kind) } } diff --git a/runtime/rust/prompty/src/model/events/thinking_event_payload.rs b/runtime/rust/prompty/src/model/events/thinking_event_payload.rs index f743fe764..1eb853b1c 100644 --- a/runtime/rust/prompty/src/model/events/thinking_event_payload.rs +++ b/runtime/rust/prompty/src/model/events/thinking_event_payload.rs @@ -27,12 +27,16 @@ impl ThinkingEventPayload { /// Load ThinkingEventPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ThinkingEventPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -41,6 +45,9 @@ impl ThinkingEventPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { token: value .get("token") @@ -50,6 +57,10 @@ impl ThinkingEventPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize ThinkingEventPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -88,6 +99,7 @@ impl serde::Serialize for ThinkingEventPayload { impl<'de> serde::Deserialize<'de> for ThinkingEventPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/token_event_payload.rs b/runtime/rust/prompty/src/model/events/token_event_payload.rs index 6bb4e7e5e..86097f31f 100644 --- a/runtime/rust/prompty/src/model/events/token_event_payload.rs +++ b/runtime/rust/prompty/src/model/events/token_event_payload.rs @@ -27,12 +27,16 @@ impl TokenEventPayload { /// Load TokenEventPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TokenEventPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -41,6 +45,9 @@ impl TokenEventPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { token: value .get("token") @@ -50,6 +57,10 @@ impl TokenEventPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize TokenEventPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -88,6 +99,7 @@ impl serde::Serialize for TokenEventPayload { impl<'de> serde::Deserialize<'de> for TokenEventPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/tool_call_complete_payload.rs b/runtime/rust/prompty/src/model/events/tool_call_complete_payload.rs index 19a006136..949d8f147 100644 --- a/runtime/rust/prompty/src/model/events/tool_call_complete_payload.rs +++ b/runtime/rust/prompty/src/model/events/tool_call_complete_payload.rs @@ -39,12 +39,16 @@ impl ToolCallCompletePayload { /// Load ToolCallCompletePayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ToolCallCompletePayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -53,6 +57,9 @@ impl ToolCallCompletePayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { id: value .get("id") @@ -79,13 +86,25 @@ impl ToolCallCompletePayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "result".to_string() + } else { + format!("{}.result", path) + }; + if let Some(child) = value.get("result") { + ToolResult::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize ToolCallCompletePayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.id { + if let Some(val) = self.id.as_ref() { result.insert("id".to_string(), serde_json::Value::String(val.clone())); } if !self.name.is_empty() { @@ -95,21 +114,21 @@ impl ToolCallCompletePayload { ); } result.insert("success".to_string(), serde_json::Value::Bool(self.success)); - if let Some(ref val) = self.result { + if let Some(val) = self.result.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("result".to_string(), nested); } } - if let Some(val) = self.duration_ms { + if let Some(val) = self.duration_ms.as_ref() { result.insert( "durationMs".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); } - if let Some(ref val) = self.error_kind { + if let Some(val) = self.error_kind.as_ref() { result.insert( "errorKind".to_string(), serde_json::Value::String(val.clone()), @@ -141,6 +160,7 @@ impl serde::Serialize for ToolCallCompletePayload { impl<'de> serde::Deserialize<'de> for ToolCallCompletePayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/tool_call_start_payload.rs b/runtime/rust/prompty/src/model/events/tool_call_start_payload.rs index 65df20aee..b32cf81c0 100644 --- a/runtime/rust/prompty/src/model/events/tool_call_start_payload.rs +++ b/runtime/rust/prompty/src/model/events/tool_call_start_payload.rs @@ -31,12 +31,16 @@ impl ToolCallStartPayload { /// Load ToolCallStartPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ToolCallStartPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl ToolCallStartPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { id: value .get("id") @@ -63,13 +70,17 @@ impl ToolCallStartPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize ToolCallStartPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.id { + if let Some(val) = self.id.as_ref() { result.insert("id".to_string(), serde_json::Value::String(val.clone())); } if !self.name.is_empty() { @@ -110,6 +121,7 @@ impl serde::Serialize for ToolCallStartPayload { impl<'de> serde::Deserialize<'de> for ToolCallStartPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/tool_execution_complete_payload.rs b/runtime/rust/prompty/src/model/events/tool_execution_complete_payload.rs index 81ef204f8..d68ed1da6 100644 --- a/runtime/rust/prompty/src/model/events/tool_execution_complete_payload.rs +++ b/runtime/rust/prompty/src/model/events/tool_execution_complete_payload.rs @@ -47,12 +47,16 @@ impl ToolExecutionCompletePayload { /// Load ToolExecutionCompletePayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ToolExecutionCompletePayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -61,6 +65,9 @@ impl ToolExecutionCompletePayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { request_id: value .get("requestId") @@ -100,19 +107,31 @@ impl ToolExecutionCompletePayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "redaction".to_string() + } else { + format!("{}.redaction", path) + }; + if let Some(child) = value.get("redaction") { + RedactionMetadata::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize ToolExecutionCompletePayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.request_id { + if let Some(val) = self.request_id.as_ref() { result.insert( "requestId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.tool_call_id { + if let Some(val) = self.tool_call_id.as_ref() { result.insert( "toolCallId".to_string(), serde_json::Value::String(val.clone()), @@ -125,24 +144,24 @@ impl ToolExecutionCompletePayload { ); } result.insert("success".to_string(), serde_json::Value::Bool(self.success)); - if let Some(ref val) = self.result { + if let Some(val) = self.result.as_ref() { result.insert("result".to_string(), val.clone()); } - if let Some(val) = self.exit_code { + if let Some(val) = self.exit_code.as_ref() { result.insert( "exitCode".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.duration_ms { + if let Some(val) = self.duration_ms.as_ref() { result.insert( "durationMs".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); } - if let Some(ref val) = self.error_kind { + if let Some(val) = self.error_kind.as_ref() { result.insert( "errorKind".to_string(), serde_json::Value::String(val.clone()), @@ -151,7 +170,7 @@ impl ToolExecutionCompletePayload { if !self.telemetry.is_null() { result.insert("telemetry".to_string(), self.telemetry.clone()); } - if let Some(ref val) = self.redaction { + if let Some(val) = self.redaction.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("redaction".to_string(), nested); @@ -188,6 +207,7 @@ impl serde::Serialize for ToolExecutionCompletePayload { impl<'de> serde::Deserialize<'de> for ToolExecutionCompletePayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/tool_execution_start_payload.rs b/runtime/rust/prompty/src/model/events/tool_execution_start_payload.rs index c53dbe073..1dca8d98e 100644 --- a/runtime/rust/prompty/src/model/events/tool_execution_start_payload.rs +++ b/runtime/rust/prompty/src/model/events/tool_execution_start_payload.rs @@ -39,12 +39,16 @@ impl ToolExecutionStartPayload { /// Load ToolExecutionStartPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ToolExecutionStartPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -53,6 +57,9 @@ impl ToolExecutionStartPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { request_id: value .get("requestId") @@ -82,19 +89,31 @@ impl ToolExecutionStartPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "redaction".to_string() + } else { + format!("{}.redaction", path) + }; + if let Some(child) = value.get("redaction") { + RedactionMetadata::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize ToolExecutionStartPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.request_id { + if let Some(val) = self.request_id.as_ref() { result.insert( "requestId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.tool_call_id { + if let Some(val) = self.tool_call_id.as_ref() { result.insert( "toolCallId".to_string(), serde_json::Value::String(val.clone()), @@ -109,13 +128,13 @@ impl ToolExecutionStartPayload { if !self.arguments.is_null() { result.insert("arguments".to_string(), self.arguments.clone()); } - if let Some(ref val) = self.working_directory { + if let Some(val) = self.working_directory.as_ref() { result.insert( "workingDirectory".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.redaction { + if let Some(val) = self.redaction.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("redaction".to_string(), nested); @@ -152,6 +171,7 @@ impl serde::Serialize for ToolExecutionStartPayload { impl<'de> serde::Deserialize<'de> for ToolExecutionStartPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/tool_result_payload.rs b/runtime/rust/prompty/src/model/events/tool_result_payload.rs index 032ea723f..9e190c388 100644 --- a/runtime/rust/prompty/src/model/events/tool_result_payload.rs +++ b/runtime/rust/prompty/src/model/events/tool_result_payload.rs @@ -31,12 +31,16 @@ impl ToolResultPayload { /// Load ToolResultPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ToolResultPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl ToolResultPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { name: value .get("name") @@ -59,6 +66,20 @@ impl ToolResultPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "result".to_string() + } else { + format!("{}.result", path) + }; + let child = value + .get("result") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + ToolResult::validate_input_at(child, &child_path)?; + Ok(()) + } + /// Serialize ToolResultPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -103,6 +124,7 @@ impl serde::Serialize for ToolResultPayload { impl<'de> serde::Deserialize<'de> for ToolResultPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/trajectory_event.rs b/runtime/rust/prompty/src/model/events/trajectory_event.rs index d77fa1b10..1c8b8d1e6 100644 --- a/runtime/rust/prompty/src/model/events/trajectory_event.rs +++ b/runtime/rust/prompty/src/model/events/trajectory_event.rs @@ -45,12 +45,16 @@ impl TrajectoryEvent { /// Load TrajectoryEvent from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TrajectoryEvent from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -59,6 +63,9 @@ impl TrajectoryEvent { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { id: value .get("id") @@ -100,34 +107,46 @@ impl TrajectoryEvent { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "redaction".to_string() + } else { + format!("{}.redaction", path) + }; + if let Some(child) = value.get("redaction") { + RedactionMetadata::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize TrajectoryEvent to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.id { + if let Some(val) = self.id.as_ref() { result.insert("id".to_string(), serde_json::Value::String(val.clone())); } - if let Some(ref val) = self.session_id { + if let Some(val) = self.session_id.as_ref() { result.insert( "sessionId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.turn_id { + if let Some(val) = self.turn_id.as_ref() { result.insert("turnId".to_string(), serde_json::Value::String(val.clone())); } - if let Some(ref val) = self.tool_call_id { + if let Some(val) = self.tool_call_id.as_ref() { result.insert( "toolCallId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(val) = self.turn_index { + if let Some(val) = self.turn_index.as_ref() { result.insert( "turnIndex".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } if !self.event_type.is_empty() { @@ -139,13 +158,13 @@ impl TrajectoryEvent { if !self.data.is_null() { result.insert("data".to_string(), self.data.clone()); } - if let Some(ref val) = self.created_at { + if let Some(val) = self.created_at.as_ref() { result.insert( "createdAt".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.redaction { + if let Some(val) = self.redaction.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("redaction".to_string(), nested); @@ -182,6 +201,7 @@ impl serde::Serialize for TrajectoryEvent { impl<'de> serde::Deserialize<'de> for TrajectoryEvent { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/turn_end_payload.rs b/runtime/rust/prompty/src/model/events/turn_end_payload.rs index dcaaf8ecd..2925398c7 100644 --- a/runtime/rust/prompty/src/model/events/turn_end_payload.rs +++ b/runtime/rust/prompty/src/model/events/turn_end_payload.rs @@ -102,12 +102,16 @@ impl TurnEndPayload { /// Load TurnEndPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TurnEndPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -116,6 +120,9 @@ impl TurnEndPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { iterations: value .get("iterations") @@ -130,31 +137,35 @@ impl TurnEndPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize TurnEndPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(val) = self.iterations { + if let Some(val) = self.iterations.as_ref() { result.insert( "iterations".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(ref val) = self.status { + if let Some(val) = self.status.as_ref() { result.insert( "status".to_string(), serde_json::Value::String(val.to_string()), ); } - if let Some(ref val) = self.response { + if let Some(val) = self.response.as_ref() { result.insert("response".to_string(), val.clone()); } - if let Some(val) = self.duration_ms { + if let Some(val) = self.duration_ms.as_ref() { result.insert( "durationMs".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); @@ -185,6 +196,7 @@ impl serde::Serialize for TurnEndPayload { impl<'de> serde::Deserialize<'de> for TurnEndPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/turn_event.rs b/runtime/rust/prompty/src/model/events/turn_event.rs index fb2861ef3..82115317f 100644 --- a/runtime/rust/prompty/src/model/events/turn_event.rs +++ b/runtime/rust/prompty/src/model/events/turn_event.rs @@ -244,7 +244,7 @@ pub struct TurnEvent { pub parent_id: Option, /// Trace span identifier associated with this event pub span_id: Option, - /// Event-specific payload. Use the typed payload model matching 'type'. + /// Event-specific payload. Values may be explicit null. Use the typed payload model matching 'type'. pub payload: serde_json::Value, } @@ -257,12 +257,16 @@ impl TurnEvent { /// Load TurnEvent from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TurnEvent from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -271,6 +275,9 @@ impl TurnEvent { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { id: value .get("id") @@ -310,6 +317,10 @@ impl TurnEvent { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize TurnEvent to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -329,22 +340,22 @@ impl TurnEvent { serde_json::Value::String(self.timestamp.clone()), ); } - if let Some(ref val) = self.turn_id { + if let Some(val) = self.turn_id.as_ref() { result.insert("turnId".to_string(), serde_json::Value::String(val.clone())); } - if let Some(val) = self.iteration { + if let Some(val) = self.iteration.as_ref() { result.insert( "iteration".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(ref val) = self.parent_id { + if let Some(val) = self.parent_id.as_ref() { result.insert( "parentId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.span_id { + if let Some(val) = self.span_id.as_ref() { result.insert("spanId".to_string(), serde_json::Value::String(val.clone())); } if !self.payload.is_null() { @@ -381,6 +392,7 @@ impl serde::Serialize for TurnEvent { impl<'de> serde::Deserialize<'de> for TurnEvent { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/turn_start_payload.rs b/runtime/rust/prompty/src/model/events/turn_start_payload.rs index b7e767ec1..5af66cfe4 100644 --- a/runtime/rust/prompty/src/model/events/turn_start_payload.rs +++ b/runtime/rust/prompty/src/model/events/turn_start_payload.rs @@ -31,12 +31,16 @@ impl TurnStartPayload { /// Load TurnStartPayload from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TurnStartPayload from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl TurnStartPayload { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { agent: value .get("agent") @@ -61,22 +68,26 @@ impl TurnStartPayload { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize TurnStartPayload to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.agent { + if let Some(val) = self.agent.as_ref() { result.insert("agent".to_string(), serde_json::Value::String(val.clone())); } if !self.inputs.is_null() { result.insert("inputs".to_string(), self.inputs.clone()); } - if let Some(val) = self.max_iterations { + if let Some(val) = self.max_iterations.as_ref() { result.insert( "maxIterations".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } ctx.process_dict(serde_json::Value::Object(result)) @@ -110,6 +121,7 @@ impl serde::Serialize for TurnStartPayload { impl<'de> serde::Deserialize<'de> for TurnStartPayload { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/turn_summary.rs b/runtime/rust/prompty/src/model/events/turn_summary.rs index 25b911473..852fab9ca 100644 --- a/runtime/rust/prompty/src/model/events/turn_summary.rs +++ b/runtime/rust/prompty/src/model/events/turn_summary.rs @@ -43,12 +43,16 @@ impl TurnSummary { /// Load TurnSummary from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TurnSummary from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -57,6 +61,9 @@ impl TurnSummary { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { turn_id: value .get("turnId") @@ -92,6 +99,18 @@ impl TurnSummary { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "usage".to_string() + } else { + format!("{}.usage", path) + }; + if let Some(child) = value.get("usage") { + TokenUsage::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize TurnSummary to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -116,34 +135,34 @@ impl TurnSummary { serde_json::Value::Number(serde_json::Number::from(self.iterations)), ); } - if let Some(val) = self.llm_calls { + if let Some(val) = self.llm_calls.as_ref() { result.insert( "llmCalls".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.tool_calls { + if let Some(val) = self.tool_calls.as_ref() { result.insert( "toolCalls".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.retries { + if let Some(val) = self.retries.as_ref() { result.insert( "retries".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(ref val) = self.usage { + if let Some(val) = self.usage.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("usage".to_string(), nested); } } - if let Some(val) = self.duration_ms { + if let Some(val) = self.duration_ms.as_ref() { result.insert( "durationMs".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); @@ -174,6 +193,7 @@ impl serde::Serialize for TurnSummary { impl<'de> serde::Deserialize<'de> for TurnSummary { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/events/turn_trace.rs b/runtime/rust/prompty/src/model/events/turn_trace.rs index f78a3c01f..5434c92ab 100644 --- a/runtime/rust/prompty/src/model/events/turn_trace.rs +++ b/runtime/rust/prompty/src/model/events/turn_trace.rs @@ -39,12 +39,16 @@ impl TurnTrace { /// Load TurnTrace from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TurnTrace from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -53,6 +57,9 @@ impl TurnTrace { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { version: value .get("version") @@ -78,6 +85,32 @@ impl TurnTrace { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("events") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "events".to_string() + } else { + format!("{}.events", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + TurnEvent::validate_input_at(entry, &entry_path)?; + } + } + let child_path = if path.is_empty() { + "summary".to_string() + } else { + format!("{}.summary", path) + }; + if let Some(child) = value.get("summary") { + TurnSummary::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize TurnTrace to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -90,13 +123,13 @@ impl TurnTrace { serde_json::Value::String(self.version.clone()), ); } - if let Some(ref val) = self.runtime { + if let Some(val) = self.runtime.as_ref() { result.insert( "runtime".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.prompty_version { + if let Some(val) = self.prompty_version.as_ref() { result.insert( "promptyVersion".to_string(), serde_json::Value::String(val.clone()), @@ -105,7 +138,7 @@ impl TurnTrace { if !self.events.is_empty() { result.insert("events".to_string(), Self::save_events(&self.events, ctx)); } - if let Some(ref val) = self.summary { + if let Some(val) = self.summary.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("summary".to_string(), nested); @@ -160,6 +193,7 @@ impl serde::Serialize for TurnTrace { impl<'de> serde::Deserialize<'de> for TurnTrace { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/memory/memory_entry.rs b/runtime/rust/prompty/src/model/memory/memory_entry.rs index 76e007685..f5dee5f8c 100644 --- a/runtime/rust/prompty/src/model/memory/memory_entry.rs +++ b/runtime/rust/prompty/src/model/memory/memory_entry.rs @@ -102,12 +102,16 @@ impl MemoryEntry { /// Load MemoryEntry from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load MemoryEntry from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -116,6 +120,9 @@ impl MemoryEntry { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { content: value .get("content") @@ -139,6 +146,10 @@ impl MemoryEntry { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize MemoryEntry to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -155,13 +166,13 @@ impl MemoryEntry { "category".to_string(), serde_json::Value::String(self.category.to_string()), ); - if let Some(ref val) = self.created_at { + if let Some(val) = self.created_at.as_ref() { result.insert( "createdAt".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref items) = self.tags { + if let Some(items) = self.tags.as_ref() { result.insert( "tags".to_string(), serde_json::to_value(items).unwrap_or(serde_json::Value::Null), @@ -193,6 +204,7 @@ impl serde::Serialize for MemoryEntry { impl<'de> serde::Deserialize<'de> for MemoryEntry { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/memory/memory_store.rs b/runtime/rust/prompty/src/model/memory/memory_store.rs index bbd28f06d..574217da2 100644 --- a/runtime/rust/prompty/src/model/memory/memory_store.rs +++ b/runtime/rust/prompty/src/model/memory/memory_store.rs @@ -29,12 +29,16 @@ impl MemoryStore { /// Load MemoryStore from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load MemoryStore from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -43,6 +47,9 @@ impl MemoryStore { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { entries: value .get("entries") @@ -51,6 +58,24 @@ impl MemoryStore { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("entries") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "entries".to_string() + } else { + format!("{}.entries", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + MemoryEntry::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize MemoryStore to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -112,6 +137,7 @@ impl serde::Serialize for MemoryStore { impl<'de> serde::Deserialize<'de> for MemoryStore { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/model/ai_resource_info.rs b/runtime/rust/prompty/src/model/model/ai_resource_info.rs index 8ae61c110..25c0f8cb0 100644 --- a/runtime/rust/prompty/src/model/model/ai_resource_info.rs +++ b/runtime/rust/prompty/src/model/model/ai_resource_info.rs @@ -37,12 +37,16 @@ impl AiResourceInfo { /// Load AiResourceInfo from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load AiResourceInfo from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -51,6 +55,9 @@ impl AiResourceInfo { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { name: value .get("name") @@ -84,6 +91,10 @@ impl AiResourceInfo { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize AiResourceInfo to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -120,7 +131,7 @@ impl AiResourceInfo { serde_json::Value::String(self.resource_group.clone()), ); } - if let Some(ref val) = self.service_url { + if let Some(val) = self.service_url.as_ref() { result.insert( "serviceUrl".to_string(), serde_json::Value::String(val.clone()), @@ -195,6 +206,7 @@ impl serde::Serialize for AiResourceInfo { impl<'de> serde::Deserialize<'de> for AiResourceInfo { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/model/invocation_usage.rs b/runtime/rust/prompty/src/model/model/invocation_usage.rs index 828b0a00d..42796ff14 100644 --- a/runtime/rust/prompty/src/model/model/invocation_usage.rs +++ b/runtime/rust/prompty/src/model/model/invocation_usage.rs @@ -31,12 +31,16 @@ impl InvocationUsage { /// Load InvocationUsage from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load InvocationUsage from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl InvocationUsage { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { input_tokens: value .get("inputTokens") @@ -61,6 +68,10 @@ impl InvocationUsage { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize InvocationUsage to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -148,6 +159,7 @@ impl serde::Serialize for InvocationUsage { impl<'de> serde::Deserialize<'de> for InvocationUsage { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/model/model.rs b/runtime/rust/prompty/src/model/model/model.rs index b51acdf18..78b531579 100644 --- a/runtime/rust/prompty/src/model/model/model.rs +++ b/runtime/rust/prompty/src/model/model/model.rs @@ -119,12 +119,16 @@ impl Model { /// Load Model from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load Model from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -133,6 +137,9 @@ impl Model { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } if let Some(s) = value.as_str() { let value = s.to_string(); return Model { @@ -165,6 +172,26 @@ impl Model { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "connection".to_string() + } else { + format!("{}.connection", path) + }; + if let Some(child) = value.get("connection") { + Connection::validate_input_at(child, &child_path)?; + } + let child_path = if path.is_empty() { + "options".to_string() + } else { + format!("{}.options", path) + }; + if let Some(child) = value.get("options") { + ModelOptions::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize Model to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -174,13 +201,13 @@ impl Model { if !self.id.is_empty() { result.insert("id".to_string(), serde_json::Value::String(self.id.clone())); } - if let Some(ref val) = self.provider { + if let Some(val) = self.provider.as_ref() { result.insert( "provider".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.api_type { + if let Some(val) = self.api_type.as_ref() { result.insert( "apiType".to_string(), serde_json::Value::String(val.to_string()), @@ -189,7 +216,7 @@ impl Model { if !self.connection.is_null() { result.insert("connection".to_string(), self.connection.clone()); } - if let Some(ref val) = self.options { + if let Some(val) = self.options.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("options".to_string(), nested); @@ -221,6 +248,7 @@ impl serde::Serialize for Model { impl<'de> serde::Deserialize<'de> for Model { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/model/model_info.rs b/runtime/rust/prompty/src/model/model/model_info.rs index 9c8b85048..9768d3a24 100644 --- a/runtime/rust/prompty/src/model/model/model_info.rs +++ b/runtime/rust/prompty/src/model/model/model_info.rs @@ -26,7 +26,7 @@ pub struct ModelInfo { pub input_modalities: Option>, /// Output modalities the model can produce (e.g., 'text', 'audio') pub output_modalities: Option>, - /// Additional provider-specific properties + /// Additional provider-specific properties. Values may be explicit null. pub additional_properties: serde_json::Value, } @@ -39,12 +39,16 @@ impl ModelInfo { /// Load ModelInfo from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ModelInfo from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -53,6 +57,9 @@ impl ModelInfo { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { id: value .get("id") @@ -94,6 +101,10 @@ impl ModelInfo { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize ModelInfo to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -103,31 +114,31 @@ impl ModelInfo { if !self.id.is_empty() { result.insert("id".to_string(), serde_json::Value::String(self.id.clone())); } - if let Some(ref val) = self.display_name { + if let Some(val) = self.display_name.as_ref() { result.insert( "displayName".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.owned_by { + if let Some(val) = self.owned_by.as_ref() { result.insert( "ownedBy".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(val) = self.context_window { + if let Some(val) = self.context_window.as_ref() { result.insert( "contextWindow".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(ref items) = self.input_modalities { + if let Some(items) = self.input_modalities.as_ref() { result.insert( "inputModalities".to_string(), serde_json::to_value(items).unwrap_or(serde_json::Value::Null), ); } - if let Some(ref items) = self.output_modalities { + if let Some(items) = self.output_modalities.as_ref() { result.insert( "outputModalities".to_string(), serde_json::to_value(items).unwrap_or(serde_json::Value::Null), @@ -215,6 +226,7 @@ impl serde::Serialize for ModelInfo { impl<'de> serde::Deserialize<'de> for ModelInfo { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/model/model_options.rs b/runtime/rust/prompty/src/model/model/model_options.rs index 0d24f084e..c82546e24 100644 --- a/runtime/rust/prompty/src/model/model/model_options.rs +++ b/runtime/rust/prompty/src/model/model/model_options.rs @@ -45,12 +45,16 @@ impl ModelOptions { /// Load ModelOptions from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ModelOptions from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -59,6 +63,9 @@ impl ModelOptions { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { frequency_penalty: value .get("frequencyPenalty") @@ -97,72 +104,76 @@ impl ModelOptions { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize ModelOptions to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(val) = self.frequency_penalty { + if let Some(val) = self.frequency_penalty.as_ref() { result.insert( "frequencyPenalty".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); } - if let Some(val) = self.max_output_tokens { + if let Some(val) = self.max_output_tokens.as_ref() { result.insert( "maxOutputTokens".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.presence_penalty { + if let Some(val) = self.presence_penalty.as_ref() { result.insert( "presencePenalty".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); } - if let Some(val) = self.seed { + if let Some(val) = self.seed.as_ref() { result.insert( "seed".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.temperature { + if let Some(val) = self.temperature.as_ref() { result.insert( "temperature".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); } - if let Some(val) = self.top_k { + if let Some(val) = self.top_k.as_ref() { result.insert( "topK".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.top_p { + if let Some(val) = self.top_p.as_ref() { result.insert( "topP".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); } - if let Some(ref items) = self.stop_sequences { + if let Some(items) = self.stop_sequences.as_ref() { result.insert( "stopSequences".to_string(), serde_json::to_value(items).unwrap_or(serde_json::Value::Null), ); } - if let Some(val) = self.allow_multiple_tool_calls { + if let Some(val) = self.allow_multiple_tool_calls.as_ref() { result.insert( "allowMultipleToolCalls".to_string(), - serde_json::Value::Bool(val), + serde_json::Value::Bool(*val), ); } if !self.additional_properties.is_null() { @@ -274,6 +285,7 @@ impl serde::Serialize for ModelOptions { impl<'de> serde::Deserialize<'de> for ModelOptions { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/model/project_info.rs b/runtime/rust/prompty/src/model/model/project_info.rs index 817123c57..d032ac1c2 100644 --- a/runtime/rust/prompty/src/model/model/project_info.rs +++ b/runtime/rust/prompty/src/model/model/project_info.rs @@ -31,12 +31,16 @@ impl ProjectInfo { /// Load ProjectInfo from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ProjectInfo from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl ProjectInfo { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { name: value .get("name") @@ -64,6 +71,10 @@ impl ProjectInfo { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize ProjectInfo to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -145,6 +156,7 @@ impl serde::Serialize for ProjectInfo { impl<'de> serde::Deserialize<'de> for ProjectInfo { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/model/subscription_info.rs b/runtime/rust/prompty/src/model/model/subscription_info.rs index bb1bbf7de..cf01d0689 100644 --- a/runtime/rust/prompty/src/model/model/subscription_info.rs +++ b/runtime/rust/prompty/src/model/model/subscription_info.rs @@ -31,12 +31,16 @@ impl SubscriptionInfo { /// Load SubscriptionInfo from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load SubscriptionInfo from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl SubscriptionInfo { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { subscription_id: value .get("subscriptionId") @@ -64,6 +71,10 @@ impl SubscriptionInfo { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize SubscriptionInfo to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -145,6 +156,7 @@ impl serde::Serialize for SubscriptionInfo { impl<'de> serde::Deserialize<'de> for SubscriptionInfo { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/model/token_usage.rs b/runtime/rust/prompty/src/model/model/token_usage.rs index 9a929155a..09c36a562 100644 --- a/runtime/rust/prompty/src/model/model/token_usage.rs +++ b/runtime/rust/prompty/src/model/model/token_usage.rs @@ -31,12 +31,16 @@ impl TokenUsage { /// Load TokenUsage from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TokenUsage from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl TokenUsage { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { prompt_tokens: value .get("promptTokens") @@ -61,28 +68,32 @@ impl TokenUsage { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize TokenUsage to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(val) = self.prompt_tokens { + if let Some(val) = self.prompt_tokens.as_ref() { result.insert( "promptTokens".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.completion_tokens { + if let Some(val) = self.completion_tokens.as_ref() { result.insert( "completionTokens".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.total_tokens { + if let Some(val) = self.total_tokens.as_ref() { result.insert( "totalTokens".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } ctx.process_dict(serde_json::Value::Object(result)) @@ -148,6 +159,7 @@ impl serde::Serialize for TokenUsage { impl<'de> serde::Deserialize<'de> for TokenUsage { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/compaction_config.rs b/runtime/rust/prompty/src/model/pipeline/compaction_config.rs index 15576d202..e69c81a5d 100644 --- a/runtime/rust/prompty/src/model/pipeline/compaction_config.rs +++ b/runtime/rust/prompty/src/model/pipeline/compaction_config.rs @@ -31,12 +31,16 @@ impl CompactionConfig { /// Load CompactionConfig from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load CompactionConfig from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl CompactionConfig { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { strategy: value .get("strategy") @@ -61,22 +68,26 @@ impl CompactionConfig { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize CompactionConfig to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.strategy { + if let Some(val) = self.strategy.as_ref() { result.insert( "strategy".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(val) = self.budget { + if let Some(val) = self.budget.as_ref() { result.insert( "budget".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } if !self.options.is_null() { @@ -113,6 +124,7 @@ impl serde::Serialize for CompactionConfig { impl<'de> serde::Deserialize<'de> for CompactionConfig { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/context_candidate.rs b/runtime/rust/prompty/src/model/pipeline/context_candidate.rs index 4848ae595..c5e183edf 100644 --- a/runtime/rust/prompty/src/model/pipeline/context_candidate.rs +++ b/runtime/rust/prompty/src/model/pipeline/context_candidate.rs @@ -35,12 +35,16 @@ impl ContextCandidate { /// Load ContextCandidate from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ContextCandidate from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -49,6 +53,9 @@ impl ContextCandidate { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { id: value .get("id") @@ -71,6 +78,24 @@ impl ContextCandidate { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("messages") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "messages".to_string() + } else { + format!("{}.messages", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Message::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize ContextCandidate to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -86,12 +111,10 @@ impl ContextCandidate { serde_json::Value::String(self.source.clone()), ); } - if !self.messages.is_empty() { - result.insert( - "messages".to_string(), - Self::save_messages(&self.messages, ctx), - ); - } + result.insert( + "messages".to_string(), + Self::save_messages(&self.messages, ctx), + ); if !self.metadata.is_null() { result.insert("metadata".to_string(), self.metadata.clone()); } @@ -149,6 +172,7 @@ impl serde::Serialize for ContextCandidate { impl<'de> serde::Deserialize<'de> for ContextCandidate { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/context_request.rs b/runtime/rust/prompty/src/model/pipeline/context_request.rs index af6eebd05..02ef05695 100644 --- a/runtime/rust/prompty/src/model/pipeline/context_request.rs +++ b/runtime/rust/prompty/src/model/pipeline/context_request.rs @@ -45,12 +45,16 @@ impl ContextRequest { /// Load ContextRequest from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ContextRequest from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -59,6 +63,9 @@ impl ContextRequest { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { session_id: value .get("sessionId") @@ -93,6 +100,34 @@ impl ContextRequest { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("messages") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "messages".to_string() + } else { + format!("{}.messages", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Message::validate_input_at(entry, &entry_path)?; + } + } + let child_path = if path.is_empty() { + "contextState".to_string() + } else { + format!("{}.contextState", path) + }; + let child = value + .get("contextState") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + InvocationContextState::validate_input_at(child, &child_path)?; + Ok(()) + } + /// Serialize ContextRequest to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -123,12 +158,10 @@ impl ContextRequest { serde_json::Value::Number(serde_json::Number::from(self.iteration)), ); } - if !self.messages.is_empty() { - result.insert( - "messages".to_string(), - Self::save_messages(&self.messages, ctx), - ); - } + result.insert( + "messages".to_string(), + Self::save_messages(&self.messages, ctx), + ); if self.stable_prefix_messages != 0 { result.insert( "stablePrefixMessages".to_string(), @@ -141,7 +174,7 @@ impl ContextRequest { result.insert("contextState".to_string(), nested); } } - if let Some(ref val) = self.inputs { + if let Some(val) = self.inputs.as_ref() { result.insert("inputs".to_string(), val.clone()); } ctx.process_dict(serde_json::Value::Object(result)) @@ -193,6 +226,7 @@ impl serde::Serialize for ContextRequest { impl<'de> serde::Deserialize<'de> for ContextRequest { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/delegated_state_reference.rs b/runtime/rust/prompty/src/model/pipeline/delegated_state_reference.rs index 44b64719b..77f9ad3e1 100644 --- a/runtime/rust/prompty/src/model/pipeline/delegated_state_reference.rs +++ b/runtime/rust/prompty/src/model/pipeline/delegated_state_reference.rs @@ -33,12 +33,16 @@ impl DelegatedStateReference { /// Load DelegatedStateReference from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load DelegatedStateReference from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -47,6 +51,9 @@ impl DelegatedStateReference { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { provider: value .get("provider") @@ -70,6 +77,10 @@ impl DelegatedStateReference { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize DelegatedStateReference to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -125,6 +136,7 @@ impl serde::Serialize for DelegatedStateReference { impl<'de> serde::Deserialize<'de> for DelegatedStateReference { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/engine_checkpoint.rs b/runtime/rust/prompty/src/model/pipeline/engine_checkpoint.rs index 35c63cd7d..ad7cfd33a 100644 --- a/runtime/rust/prompty/src/model/pipeline/engine_checkpoint.rs +++ b/runtime/rust/prompty/src/model/pipeline/engine_checkpoint.rs @@ -85,12 +85,16 @@ impl EngineCheckpoint { /// Load EngineCheckpoint from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load EngineCheckpoint from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -99,6 +103,9 @@ impl EngineCheckpoint { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { id: value .get("id") @@ -195,6 +202,78 @@ impl EngineCheckpoint { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("messages") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "messages".to_string() + } else { + format!("{}.messages", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Message::validate_input_at(entry, &entry_path)?; + } + } + if let Some(entries) = value + .get("pendingToolRequests") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "pendingToolRequests".to_string() + } else { + format!("{}.pendingToolRequests", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + ModelToolRequest::validate_input_at(entry, &entry_path)?; + } + } + if let Some(entries) = value + .get("completedToolResults") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "completedToolResults".to_string() + } else { + format!("{}.completedToolResults", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + ModelToolResult::validate_input_at(entry, &entry_path)?; + } + } + let child_path = if path.is_empty() { + "modelReconciliation".to_string() + } else { + format!("{}.modelReconciliation", path) + }; + if let Some(child) = value.get("modelReconciliation") { + ModelReconciliationState::validate_input_at(child, &child_path)?; + } + let child_path = if path.is_empty() { + "pendingModelResponse".to_string() + } else { + format!("{}.pendingModelResponse", path) + }; + if let Some(child) = value.get("pendingModelResponse") { + ModelInvocationResponse::validate_input_at(child, &child_path)?; + } + let child_path = if path.is_empty() { + "contextState".to_string() + } else { + format!("{}.contextState", path) + }; + let child = value + .get("contextState") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + InvocationContextState::validate_input_at(child, &child_path)?; + Ok(()) + } + /// Serialize EngineCheckpoint to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -222,7 +301,7 @@ impl EngineCheckpoint { serde_json::Value::String(self.run_id.clone()), ); } - if let Some(ref val) = self.parent_run_id { + if let Some(val) = self.parent_run_id.as_ref() { result.insert( "parentRunId".to_string(), serde_json::Value::String(val.clone()), @@ -246,39 +325,33 @@ impl EngineCheckpoint { serde_json::Value::Number(serde_json::Number::from(self.last_sequence)), ); } - if !self.messages.is_empty() { - result.insert( - "messages".to_string(), - Self::save_messages(&self.messages, ctx), - ); - } + result.insert( + "messages".to_string(), + Self::save_messages(&self.messages, ctx), + ); if self.stable_prefix_messages != 0 { result.insert( "stablePrefixMessages".to_string(), serde_json::Value::Number(serde_json::Number::from(self.stable_prefix_messages)), ); } - if let Some(ref val) = self.inputs { + if let Some(val) = self.inputs.as_ref() { result.insert("inputs".to_string(), val.clone()); } - if let Some(ref val) = self.active_invocation_id { + if let Some(val) = self.active_invocation_id.as_ref() { result.insert( "activeInvocationId".to_string(), serde_json::Value::String(val.clone()), ); } - if !self.pending_tool_requests.is_empty() { - result.insert( - "pendingToolRequests".to_string(), - Self::save_pending_tool_requests(&self.pending_tool_requests, ctx), - ); - } - if !self.completed_tool_results.is_empty() { - result.insert( - "completedToolResults".to_string(), - Self::save_completed_tool_results(&self.completed_tool_results, ctx), - ); - } + result.insert( + "pendingToolRequests".to_string(), + Self::save_pending_tool_requests(&self.pending_tool_requests, ctx), + ); + result.insert( + "completedToolResults".to_string(), + Self::save_completed_tool_results(&self.completed_tool_results, ctx), + ); if self.completed_model_iterations != 0 { result.insert( "completedModelIterations".to_string(), @@ -291,20 +364,20 @@ impl EngineCheckpoint { "reconciliationRequired".to_string(), serde_json::Value::Bool(self.reconciliation_required), ); - if let Some(ref val) = self.model_reconciliation { + if let Some(val) = self.model_reconciliation.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("modelReconciliation".to_string(), nested); } } - if let Some(ref val) = self.pending_output { + if let Some(val) = self.pending_output.as_ref() { result.insert("pendingOutput".to_string(), val.clone()); } result.insert( "finalOutputReady".to_string(), serde_json::Value::Bool(self.final_output_ready), ); - if let Some(ref val) = self.pending_model_response { + if let Some(val) = self.pending_model_response.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("pendingModelResponse".to_string(), nested); @@ -369,7 +442,7 @@ impl EngineCheckpoint { } /// Load a collection of ModelToolRequest from a JSON value. - /// Handles both array format `[{...}]` and dict format `{"name": {...}}`. + /// Handles both array format `[{...}]`. fn load_pending_tool_requests( data: &serde_json::Value, ctx: &LoadContext, @@ -380,24 +453,6 @@ impl EngineCheckpoint { .map(|v| ModelToolRequest::load_from_value(v, ctx)) .collect(), - serde_json::Value::Object(obj) => obj - .iter() - .filter_map(|(name, value)| { - if value.is_array() { - return None; - } - let mut v = if value.is_object() { - value.clone() - } else { - serde_json::json!({ "id": value }) - }; - if let serde_json::Value::Object(ref mut m) = v { - m.entry("name".to_string()) - .or_insert_with(|| serde_json::Value::String(name.clone())); - } - Some(ModelToolRequest::load_from_value(&v, ctx)) - }) - .collect(), _ => Vec::new(), } } @@ -407,34 +462,16 @@ impl EngineCheckpoint { items: &[ModelToolRequest], ctx: &SaveContext, ) -> serde_json::Value { - if ctx.collection_format == "array" { - return serde_json::Value::Array( - items - .iter() - .map(|item| item.to_value(ctx)) - .collect::>(), - ); - } - // Object format: use name as key - let mut result = serde_json::Map::new(); - for item in items { - let mut item_data = match item.to_value(ctx) { - serde_json::Value::Object(m) => m, - other => { - let mut m = serde_json::Map::new(); - m.insert("value".to_string(), other); - m - } - }; - if let Some(serde_json::Value::String(name)) = item_data.remove("name") { - result.insert(name, serde_json::Value::Object(item_data)); - } - } - serde_json::Value::Object(result) + serde_json::Value::Array( + items + .iter() + .map(|item| item.to_value(ctx)) + .collect::>(), + ) } /// Load a collection of ModelToolResult from a JSON value. - /// Handles both array format `[{...}]` and dict format `{"name": {...}}`. + /// Handles both array format `[{...}]`. fn load_completed_tool_results( data: &serde_json::Value, ctx: &LoadContext, @@ -445,24 +482,6 @@ impl EngineCheckpoint { .map(|v| ModelToolResult::load_from_value(v, ctx)) .collect(), - serde_json::Value::Object(obj) => obj - .iter() - .filter_map(|(name, value)| { - if value.is_array() { - return None; - } - let mut v = if value.is_object() { - value.clone() - } else { - serde_json::json!({ "requestId": value }) - }; - if let serde_json::Value::Object(ref mut m) = v { - m.entry("name".to_string()) - .or_insert_with(|| serde_json::Value::String(name.clone())); - } - Some(ModelToolResult::load_from_value(&v, ctx)) - }) - .collect(), _ => Vec::new(), } } @@ -472,30 +491,12 @@ impl EngineCheckpoint { items: &[ModelToolResult], ctx: &SaveContext, ) -> serde_json::Value { - if ctx.collection_format == "array" { - return serde_json::Value::Array( - items - .iter() - .map(|item| item.to_value(ctx)) - .collect::>(), - ); - } - // Object format: use name as key - let mut result = serde_json::Map::new(); - for item in items { - let mut item_data = match item.to_value(ctx) { - serde_json::Value::Object(m) => m, - other => { - let mut m = serde_json::Map::new(); - m.insert("value".to_string(), other); - m - } - }; - if let Some(serde_json::Value::String(name)) = item_data.remove("name") { - result.insert(name, serde_json::Value::Object(item_data)); - } - } - serde_json::Value::Object(result) + serde_json::Value::Array( + items + .iter() + .map(|item| item.to_value(ctx)) + .collect::>(), + ) } } @@ -511,6 +512,7 @@ impl serde::Serialize for EngineCheckpoint { impl<'de> serde::Deserialize<'de> for EngineCheckpoint { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/engine_durability_port.rs b/runtime/rust/prompty/src/model/pipeline/engine_durability_port.rs new file mode 100644 index 000000000..70c93b236 --- /dev/null +++ b/runtime/rust/prompty/src/model/pipeline/engine_durability_port.rs @@ -0,0 +1,30 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +#![allow( + unused_imports, + dead_code, + non_camel_case_types, + unused_variables, + clippy::all +)] + +use super::engine_checkpoint::EngineCheckpoint; + +use super::engine_event::EngineEvent; + +/// Persists semantic engine events and checkpoints without runtime cancellation. +#[async_trait::async_trait] +pub trait EngineDurabilityPort: Send + Sync { + /// Append one semantic engine event durably + async fn append( + &self, + event: &EngineEvent, + ) -> Result<(), Box>; + /// Atomically append semantic engine events and persist the checkpoint that reflects them + async fn append_with_checkpoint( + &self, + events: &Vec, + checkpoint: &EngineCheckpoint, + ) -> Result<(), Box>; +} diff --git a/runtime/rust/prompty/src/model/pipeline/engine_event.rs b/runtime/rust/prompty/src/model/pipeline/engine_event.rs index 62793cb40..ab34cf348 100644 --- a/runtime/rust/prompty/src/model/pipeline/engine_event.rs +++ b/runtime/rust/prompty/src/model/pipeline/engine_event.rs @@ -259,12 +259,16 @@ impl EngineEvent { /// Load EngineEvent from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load EngineEvent from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -273,6 +277,9 @@ impl EngineEvent { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { sequence: value.get("sequence").and_then(|v| v.as_i64()).unwrap_or(0), id: value @@ -325,6 +332,10 @@ impl EngineEvent { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize EngineEvent to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -364,7 +375,7 @@ impl EngineEvent { serde_json::Value::String(self.run_id.clone()), ); } - if let Some(ref val) = self.parent_run_id { + if let Some(val) = self.parent_run_id.as_ref() { result.insert( "parentRunId".to_string(), serde_json::Value::String(val.clone()), @@ -376,23 +387,23 @@ impl EngineEvent { serde_json::Value::Number(serde_json::Number::from(self.delegation_depth)), ); } - if let Some(ref val) = self.invocation_id { + if let Some(val) = self.invocation_id.as_ref() { result.insert( "invocationId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(val) = self.iteration { + if let Some(val) = self.iteration.as_ref() { result.insert( "iteration".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } result.insert( "kind".to_string(), serde_json::Value::String(self.kind.to_string()), ); - if let Some(ref val) = self.payload { + if let Some(val) = self.payload.as_ref() { result.insert("payload".to_string(), val.clone()); } ctx.process_dict(serde_json::Value::Object(result)) @@ -421,6 +432,7 @@ impl serde::Serialize for EngineEvent { impl<'de> serde::Deserialize<'de> for EngineEvent { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/engine_permission_decision.rs b/runtime/rust/prompty/src/model/pipeline/engine_permission_decision.rs index 4031c1ba6..f3e3c5983 100644 --- a/runtime/rust/prompty/src/model/pipeline/engine_permission_decision.rs +++ b/runtime/rust/prompty/src/model/pipeline/engine_permission_decision.rs @@ -31,12 +31,16 @@ impl EnginePermissionDecision { /// Load EnginePermissionDecision from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load EnginePermissionDecision from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl EnginePermissionDecision { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { approved: value .get("approved") @@ -61,6 +68,10 @@ impl EnginePermissionDecision { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize EnginePermissionDecision to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -71,7 +82,7 @@ impl EnginePermissionDecision { "approved".to_string(), serde_json::Value::Bool(self.approved), ); - if let Some(ref val) = self.reason { + if let Some(val) = self.reason.as_ref() { result.insert("reason".to_string(), serde_json::Value::String(val.clone())); } if !self.metadata.is_null() { @@ -108,6 +119,7 @@ impl serde::Serialize for EnginePermissionDecision { impl<'de> serde::Deserialize<'de> for EnginePermissionDecision { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/engine_permission_port.rs b/runtime/rust/prompty/src/model/pipeline/engine_permission_port.rs new file mode 100644 index 000000000..226587d68 --- /dev/null +++ b/runtime/rust/prompty/src/model/pipeline/engine_permission_port.rs @@ -0,0 +1,27 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +#![allow( + unused_imports, + dead_code, + non_camel_case_types, + unused_variables, + clippy::all +)] + +use crate::engine::CancellationToken; + +use super::engine_permission_decision::EnginePermissionDecision; + +use super::model_tool_request::ModelToolRequest; + +/// Authorizes model-requested tools at a runtime cancellation boundary. +#[async_trait::async_trait] +pub trait EnginePermissionPort: Send + Sync { + /// Authorize one model-requested tool before execution + async fn authorize( + &self, + request: &ModelToolRequest, + cancellation: &CancellationToken, + ) -> Result>; +} diff --git a/runtime/rust/prompty/src/model/pipeline/engine_post_commit_port.rs b/runtime/rust/prompty/src/model/pipeline/engine_post_commit_port.rs new file mode 100644 index 000000000..45cc03080 --- /dev/null +++ b/runtime/rust/prompty/src/model/pipeline/engine_post_commit_port.rs @@ -0,0 +1,26 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +#![allow( + unused_imports, + dead_code, + non_camel_case_types, + unused_variables, + clippy::all +)] + +use crate::engine::CancellationToken; + +use super::turn_commit::TurnCommit; + +/// Runs non-fatal host effects after a turn is durably committed. +#[async_trait::async_trait] +pub trait EnginePostCommitPort: Send + Sync { + /// Run one idempotent host effect after the turn is durably committed + async fn after_commit( + &self, + effect_id: &String, + commit: &TurnCommit, + cancellation: &CancellationToken, + ) -> Result<(), Box>; +} diff --git a/runtime/rust/prompty/src/model/pipeline/engine_tool_port.rs b/runtime/rust/prompty/src/model/pipeline/engine_tool_port.rs new file mode 100644 index 000000000..41779fea4 --- /dev/null +++ b/runtime/rust/prompty/src/model/pipeline/engine_tool_port.rs @@ -0,0 +1,27 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +#![allow( + unused_imports, + dead_code, + non_camel_case_types, + unused_variables, + clippy::all +)] + +use crate::engine::CancellationToken; + +use super::model_tool_request::ModelToolRequest; + +use super::model_tool_result::ModelToolResult; + +/// Executes authorized model-requested tools at a runtime cancellation boundary. +#[async_trait::async_trait] +pub trait EngineToolPort: Send + Sync { + /// Execute one authorized model-requested tool + async fn execute( + &self, + request: &ModelToolRequest, + cancellation: &CancellationToken, + ) -> Result>; +} diff --git a/runtime/rust/prompty/src/model/pipeline/executor.rs b/runtime/rust/prompty/src/model/pipeline/executor.rs index de48cd8b0..e04f32c0f 100644 --- a/runtime/rust/prompty/src/model/pipeline/executor.rs +++ b/runtime/rust/prompty/src/model/pipeline/executor.rs @@ -9,6 +9,8 @@ clippy::all )] +use crate::engine::CancellationToken; + use super::super::conversation::message::Message; use super::super::agent::prompty::Prompty; @@ -23,12 +25,14 @@ pub trait Executor: Send + Sync { &self, agent: &Prompty, messages: &Vec, + cancellation: &CancellationToken, ) -> Result>; /// Call an LLM provider and return a streaming response. Returns a language-specific async iterable/stream of raw chunks. Not all providers support streaming; the default implementation should signal lack of support. async fn execute_stream( &self, agent: &Prompty, messages: &Vec, + cancellation: &CancellationToken, ) -> Result> { Err("not supported".into()) } diff --git a/runtime/rust/prompty/src/model/pipeline/final_output_policy_request.rs b/runtime/rust/prompty/src/model/pipeline/final_output_policy_request.rs index 4f96ebd97..314f5443a 100644 --- a/runtime/rust/prompty/src/model/pipeline/final_output_policy_request.rs +++ b/runtime/rust/prompty/src/model/pipeline/final_output_policy_request.rs @@ -39,12 +39,16 @@ impl FinalOutputPolicyRequest { /// Load FinalOutputPolicyRequest from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load FinalOutputPolicyRequest from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -53,6 +57,9 @@ impl FinalOutputPolicyRequest { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { session_id: value .get("sessionId") @@ -74,6 +81,24 @@ impl FinalOutputPolicyRequest { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("messages") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "messages".to_string() + } else { + format!("{}.messages", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Message::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize FinalOutputPolicyRequest to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -98,16 +123,14 @@ impl FinalOutputPolicyRequest { serde_json::Value::Number(serde_json::Number::from(self.iteration)), ); } - if !self.messages.is_empty() { - result.insert( - "messages".to_string(), - Self::save_messages(&self.messages, ctx), - ); - } - if let Some(ref val) = self.output { + result.insert( + "messages".to_string(), + Self::save_messages(&self.messages, ctx), + ); + if let Some(val) = self.output.as_ref() { result.insert("output".to_string(), val.clone()); } - if let Some(ref val) = self.inputs { + if let Some(val) = self.inputs.as_ref() { result.insert("inputs".to_string(), val.clone()); } ctx.process_dict(serde_json::Value::Object(result)) @@ -159,6 +182,7 @@ impl serde::Serialize for FinalOutputPolicyRequest { impl<'de> serde::Deserialize<'de> for FinalOutputPolicyRequest { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/final_output_policy_result.rs b/runtime/rust/prompty/src/model/pipeline/final_output_policy_result.rs index 20a4e42d0..209915528 100644 --- a/runtime/rust/prompty/src/model/pipeline/final_output_policy_result.rs +++ b/runtime/rust/prompty/src/model/pipeline/final_output_policy_result.rs @@ -29,12 +29,16 @@ impl FinalOutputPolicyResult { /// Load FinalOutputPolicyResult from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load FinalOutputPolicyResult from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -43,6 +47,9 @@ impl FinalOutputPolicyResult { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { output: value.get("output").cloned(), metadata: value @@ -52,13 +59,17 @@ impl FinalOutputPolicyResult { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize FinalOutputPolicyResult to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.output { + if let Some(val) = self.output.as_ref() { result.insert("output".to_string(), val.clone()); } if !self.metadata.is_null() { @@ -95,6 +106,7 @@ impl serde::Serialize for FinalOutputPolicyResult { impl<'de> serde::Deserialize<'de> for FinalOutputPolicyResult { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/host_policy_request.rs b/runtime/rust/prompty/src/model/pipeline/host_policy_request.rs index a388243f4..d88312b1c 100644 --- a/runtime/rust/prompty/src/model/pipeline/host_policy_request.rs +++ b/runtime/rust/prompty/src/model/pipeline/host_policy_request.rs @@ -39,12 +39,16 @@ impl HostPolicyRequest { /// Load HostPolicyRequest from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load HostPolicyRequest from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -53,6 +57,9 @@ impl HostPolicyRequest { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { session_id: value .get("sessionId") @@ -77,6 +84,24 @@ impl HostPolicyRequest { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("messages") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "messages".to_string() + } else { + format!("{}.messages", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Message::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize HostPolicyRequest to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -101,19 +126,17 @@ impl HostPolicyRequest { serde_json::Value::Number(serde_json::Number::from(self.iteration)), ); } - if !self.messages.is_empty() { - result.insert( - "messages".to_string(), - Self::save_messages(&self.messages, ctx), - ); - } + result.insert( + "messages".to_string(), + Self::save_messages(&self.messages, ctx), + ); if self.stable_prefix_messages != 0 { result.insert( "stablePrefixMessages".to_string(), serde_json::Value::Number(serde_json::Number::from(self.stable_prefix_messages)), ); } - if let Some(ref val) = self.inputs { + if let Some(val) = self.inputs.as_ref() { result.insert("inputs".to_string(), val.clone()); } ctx.process_dict(serde_json::Value::Object(result)) @@ -165,6 +188,7 @@ impl serde::Serialize for HostPolicyRequest { impl<'de> serde::Deserialize<'de> for HostPolicyRequest { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/host_policy_result.rs b/runtime/rust/prompty/src/model/pipeline/host_policy_result.rs index 46e847cb8..9c3f6b279 100644 --- a/runtime/rust/prompty/src/model/pipeline/host_policy_result.rs +++ b/runtime/rust/prompty/src/model/pipeline/host_policy_result.rs @@ -33,12 +33,16 @@ impl HostPolicyResult { /// Load HostPolicyResult from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load HostPolicyResult from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -47,6 +51,9 @@ impl HostPolicyResult { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { messages: value .get("messages") @@ -63,18 +70,34 @@ impl HostPolicyResult { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("messages") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "messages".to_string() + } else { + format!("{}.messages", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Message::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize HostPolicyResult to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if !self.messages.is_empty() { - result.insert( - "messages".to_string(), - Self::save_messages(&self.messages, ctx), - ); - } + result.insert( + "messages".to_string(), + Self::save_messages(&self.messages, ctx), + ); if self.stable_prefix_messages != 0 { result.insert( "stablePrefixMessages".to_string(), @@ -138,6 +161,7 @@ impl serde::Serialize for HostPolicyResult { impl<'de> serde::Deserialize<'de> for HostPolicyResult { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/invocation_context_decision.rs b/runtime/rust/prompty/src/model/pipeline/invocation_context_decision.rs index a594876b6..e5dd923ae 100644 --- a/runtime/rust/prompty/src/model/pipeline/invocation_context_decision.rs +++ b/runtime/rust/prompty/src/model/pipeline/invocation_context_decision.rs @@ -100,12 +100,16 @@ impl InvocationContextDecision { /// Load InvocationContextDecision from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load InvocationContextDecision from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -114,6 +118,9 @@ impl InvocationContextDecision { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { candidate_id: value .get("candidateId") @@ -142,6 +149,10 @@ impl InvocationContextDecision { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize InvocationContextDecision to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -164,16 +175,16 @@ impl InvocationContextDecision { serde_json::Value::String(self.reason.clone()), ); } - if let Some(val) = self.rank { + if let Some(val) = self.rank.as_ref() { result.insert( "rank".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.estimated_tokens { + if let Some(val) = self.estimated_tokens.as_ref() { result.insert( "estimatedTokens".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } if !self.metadata.is_null() { @@ -210,6 +221,7 @@ impl serde::Serialize for InvocationContextDecision { impl<'de> serde::Deserialize<'de> for InvocationContextDecision { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/invocation_context_state.rs b/runtime/rust/prompty/src/model/pipeline/invocation_context_state.rs index e709fde53..ca8639a58 100644 --- a/runtime/rust/prompty/src/model/pipeline/invocation_context_state.rs +++ b/runtime/rust/prompty/src/model/pipeline/invocation_context_state.rs @@ -101,12 +101,16 @@ impl InvocationContextState { /// Load InvocationContextState from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load InvocationContextState from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -115,6 +119,9 @@ impl InvocationContextState { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { portability: value .get("portability") @@ -128,6 +135,24 @@ impl InvocationContextState { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("delegatedState") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "delegatedState".to_string() + } else { + format!("{}.delegatedState", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + DelegatedStateReference::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize InvocationContextState to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -138,12 +163,10 @@ impl InvocationContextState { "portability".to_string(), serde_json::Value::String(self.portability.to_string()), ); - if !self.delegated_state.is_empty() { - result.insert( - "delegatedState".to_string(), - Self::save_delegated_state(&self.delegated_state, ctx), - ); - } + result.insert( + "delegatedState".to_string(), + Self::save_delegated_state(&self.delegated_state, ctx), + ); ctx.process_dict(serde_json::Value::Object(result)) } @@ -199,6 +222,7 @@ impl serde::Serialize for InvocationContextState { impl<'de> serde::Deserialize<'de> for InvocationContextState { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/mod.rs b/runtime/rust/prompty/src/model/pipeline/mod.rs index 58f4aaf7f..05cdd37cf 100644 --- a/runtime/rust/prompty/src/model/pipeline/mod.rs +++ b/runtime/rust/prompty/src/model/pipeline/mod.rs @@ -54,6 +54,18 @@ pub use turn_commit::*; pub mod turn_engine_result; pub use turn_engine_result::*; +pub mod engine_permission_port; +pub use engine_permission_port::*; + +pub mod engine_tool_port; +pub use engine_tool_port::*; + +pub mod engine_durability_port; +pub use engine_durability_port::*; + +pub mod engine_post_commit_port; +pub use engine_post_commit_port::*; + pub mod host_policy_request; pub use host_policy_request::*; diff --git a/runtime/rust/prompty/src/model/pipeline/model_invocation_context_snapshot.rs b/runtime/rust/prompty/src/model/pipeline/model_invocation_context_snapshot.rs index dc6def849..b6bc35010 100644 --- a/runtime/rust/prompty/src/model/pipeline/model_invocation_context_snapshot.rs +++ b/runtime/rust/prompty/src/model/pipeline/model_invocation_context_snapshot.rs @@ -51,12 +51,16 @@ impl ModelInvocationContextSnapshot { /// Load ModelInvocationContextSnapshot from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ModelInvocationContextSnapshot from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -65,6 +69,9 @@ impl ModelInvocationContextSnapshot { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { id: value .get("id") @@ -111,6 +118,48 @@ impl ModelInvocationContextSnapshot { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("messages") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "messages".to_string() + } else { + format!("{}.messages", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Message::validate_input_at(entry, &entry_path)?; + } + } + if let Some(entries) = value + .get("decisions") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "decisions".to_string() + } else { + format!("{}.decisions", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + InvocationContextDecision::validate_input_at(entry, &entry_path)?; + } + } + let child_path = if path.is_empty() { + "contextState".to_string() + } else { + format!("{}.contextState", path) + }; + let child = value + .get("contextState") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + InvocationContextState::validate_input_at(child, &child_path)?; + Ok(()) + } + /// Serialize ModelInvocationContextSnapshot to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -144,18 +193,14 @@ impl ModelInvocationContextSnapshot { serde_json::Value::Number(serde_json::Number::from(self.iteration)), ); } - if !self.messages.is_empty() { - result.insert( - "messages".to_string(), - Self::save_messages(&self.messages, ctx), - ); - } - if !self.decisions.is_empty() { - result.insert( - "decisions".to_string(), - Self::save_decisions(&self.decisions, ctx), - ); - } + result.insert( + "messages".to_string(), + Self::save_messages(&self.messages, ctx), + ); + result.insert( + "decisions".to_string(), + Self::save_decisions(&self.decisions, ctx), + ); if self.stable_prefix_messages != 0 { result.insert( "stablePrefixMessages".to_string(), @@ -251,6 +296,7 @@ impl serde::Serialize for ModelInvocationContextSnapshot { impl<'de> serde::Deserialize<'de> for ModelInvocationContextSnapshot { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/model_invocation_request.rs b/runtime/rust/prompty/src/model/pipeline/model_invocation_request.rs index ef342bf7e..e63747d9a 100644 --- a/runtime/rust/prompty/src/model/pipeline/model_invocation_request.rs +++ b/runtime/rust/prompty/src/model/pipeline/model_invocation_request.rs @@ -29,12 +29,16 @@ impl ModelInvocationRequest { /// Load ModelInvocationRequest from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ModelInvocationRequest from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -43,6 +47,9 @@ impl ModelInvocationRequest { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { context: value .get("context") @@ -52,6 +59,20 @@ impl ModelInvocationRequest { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "context".to_string() + } else { + format!("{}.context", path) + }; + let child = value + .get("context") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + ModelInvocationContextSnapshot::validate_input_at(child, &child_path)?; + Ok(()) + } + /// Serialize ModelInvocationRequest to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -90,6 +111,7 @@ impl serde::Serialize for ModelInvocationRequest { impl<'de> serde::Deserialize<'de> for ModelInvocationRequest { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/model_invocation_response.rs b/runtime/rust/prompty/src/model/pipeline/model_invocation_response.rs index b956e90fd..1975fdaf0 100644 --- a/runtime/rust/prompty/src/model/pipeline/model_invocation_response.rs +++ b/runtime/rust/prompty/src/model/pipeline/model_invocation_response.rs @@ -45,12 +45,16 @@ impl ModelInvocationResponse { /// Load ModelInvocationResponse from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ModelInvocationResponse from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -59,6 +63,9 @@ impl ModelInvocationResponse { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { output: value.get("output").cloned(), usage: value @@ -84,34 +91,78 @@ impl ModelInvocationResponse { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "usage".to_string() + } else { + format!("{}.usage", path) + }; + if let Some(child) = value.get("usage") { + InvocationUsage::validate_input_at(child, &child_path)?; + } + if let Some(entries) = value + .get("assistantMessages") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "assistantMessages".to_string() + } else { + format!("{}.assistantMessages", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Message::validate_input_at(entry, &entry_path)?; + } + } + if let Some(entries) = value + .get("toolRequests") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "toolRequests".to_string() + } else { + format!("{}.toolRequests", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + ModelToolRequest::validate_input_at(entry, &entry_path)?; + } + } + let child_path = if path.is_empty() { + "nextContextState".to_string() + } else { + format!("{}.nextContextState", path) + }; + if let Some(child) = value.get("nextContextState") { + InvocationContextState::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize ModelInvocationResponse to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.output { + if let Some(val) = self.output.as_ref() { result.insert("output".to_string(), val.clone()); } - if let Some(ref val) = self.usage { + if let Some(val) = self.usage.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("usage".to_string(), nested); } } - if !self.assistant_messages.is_empty() { - result.insert( - "assistantMessages".to_string(), - Self::save_assistant_messages(&self.assistant_messages, ctx), - ); - } - if !self.tool_requests.is_empty() { - result.insert( - "toolRequests".to_string(), - Self::save_tool_requests(&self.tool_requests, ctx), - ); - } - if let Some(ref val) = self.next_context_state { + result.insert( + "assistantMessages".to_string(), + Self::save_assistant_messages(&self.assistant_messages, ctx), + ); + result.insert( + "toolRequests".to_string(), + Self::save_tool_requests(&self.tool_requests, ctx), + ); + if let Some(val) = self.next_context_state.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("nextContextState".to_string(), nested); @@ -162,7 +213,7 @@ impl ModelInvocationResponse { } /// Load a collection of ModelToolRequest from a JSON value. - /// Handles both array format `[{...}]` and dict format `{"name": {...}}`. + /// Handles both array format `[{...}]`. fn load_tool_requests(data: &serde_json::Value, ctx: &LoadContext) -> Vec { match data { serde_json::Value::Array(arr) => arr @@ -170,54 +221,18 @@ impl ModelInvocationResponse { .map(|v| ModelToolRequest::load_from_value(v, ctx)) .collect(), - serde_json::Value::Object(obj) => obj - .iter() - .filter_map(|(name, value)| { - if value.is_array() { - return None; - } - let mut v = if value.is_object() { - value.clone() - } else { - serde_json::json!({ "id": value }) - }; - if let serde_json::Value::Object(ref mut m) = v { - m.entry("name".to_string()) - .or_insert_with(|| serde_json::Value::String(name.clone())); - } - Some(ModelToolRequest::load_from_value(&v, ctx)) - }) - .collect(), _ => Vec::new(), } } /// Save a collection of ModelToolRequest to a JSON value. fn save_tool_requests(items: &[ModelToolRequest], ctx: &SaveContext) -> serde_json::Value { - if ctx.collection_format == "array" { - return serde_json::Value::Array( - items - .iter() - .map(|item| item.to_value(ctx)) - .collect::>(), - ); - } - // Object format: use name as key - let mut result = serde_json::Map::new(); - for item in items { - let mut item_data = match item.to_value(ctx) { - serde_json::Value::Object(m) => m, - other => { - let mut m = serde_json::Map::new(); - m.insert("value".to_string(), other); - m - } - }; - if let Some(serde_json::Value::String(name)) = item_data.remove("name") { - result.insert(name, serde_json::Value::Object(item_data)); - } - } - serde_json::Value::Object(result) + serde_json::Value::Array( + items + .iter() + .map(|item| item.to_value(ctx)) + .collect::>(), + ) } } @@ -233,6 +248,7 @@ impl serde::Serialize for ModelInvocationResponse { impl<'de> serde::Deserialize<'de> for ModelInvocationResponse { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/model_reconciliation_state.rs b/runtime/rust/prompty/src/model/pipeline/model_reconciliation_state.rs index 468cbe65c..b09e0947b 100644 --- a/runtime/rust/prompty/src/model/pipeline/model_reconciliation_state.rs +++ b/runtime/rust/prompty/src/model/pipeline/model_reconciliation_state.rs @@ -37,12 +37,16 @@ impl ModelReconciliationState { /// Load ModelReconciliationState from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ModelReconciliationState from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -51,6 +55,9 @@ impl ModelReconciliationState { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { invocation_id: value .get("invocationId") @@ -78,6 +85,20 @@ impl ModelReconciliationState { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "request".to_string() + } else { + format!("{}.request", path) + }; + let child = value + .get("request") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + ModelInvocationRequest::validate_input_at(child, &child_path)?; + Ok(()) + } + /// Serialize ModelReconciliationState to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -142,6 +163,7 @@ impl serde::Serialize for ModelReconciliationState { impl<'de> serde::Deserialize<'de> for ModelReconciliationState { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/model_tool_request.rs b/runtime/rust/prompty/src/model/pipeline/model_tool_request.rs index 97172e937..2b78d3d01 100644 --- a/runtime/rust/prompty/src/model/pipeline/model_tool_request.rs +++ b/runtime/rust/prompty/src/model/pipeline/model_tool_request.rs @@ -33,12 +33,16 @@ impl ModelToolRequest { /// Load ModelToolRequest from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ModelToolRequest from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -47,6 +51,9 @@ impl ModelToolRequest { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { id: value .get("id") @@ -66,6 +73,10 @@ impl ModelToolRequest { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize ModelToolRequest to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -81,7 +92,7 @@ impl ModelToolRequest { serde_json::Value::String(self.name.clone()), ); } - if let Some(ref val) = self.arguments { + if let Some(val) = self.arguments.as_ref() { result.insert("arguments".to_string(), val.clone()); } if !self.metadata.is_null() { @@ -118,6 +129,7 @@ impl serde::Serialize for ModelToolRequest { impl<'de> serde::Deserialize<'de> for ModelToolRequest { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/model_tool_result.rs b/runtime/rust/prompty/src/model/pipeline/model_tool_result.rs index c5c252c5f..8403c55a8 100644 --- a/runtime/rust/prompty/src/model/pipeline/model_tool_result.rs +++ b/runtime/rust/prompty/src/model/pipeline/model_tool_result.rs @@ -107,12 +107,16 @@ impl ModelToolResult { /// Load ModelToolResult from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ModelToolResult from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -121,6 +125,9 @@ impl ModelToolResult { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { request_id: value .get("requestId") @@ -149,6 +156,10 @@ impl ModelToolResult { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize ModelToolResult to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -171,10 +182,10 @@ impl ModelToolResult { "outcome".to_string(), serde_json::Value::String(self.outcome.to_string()), ); - if let Some(ref val) = self.output { + if let Some(val) = self.output.as_ref() { result.insert("output".to_string(), val.clone()); } - if let Some(ref val) = self.error_kind { + if let Some(val) = self.error_kind.as_ref() { result.insert( "errorKind".to_string(), serde_json::Value::String(val.clone()), @@ -214,6 +225,7 @@ impl serde::Serialize for ModelToolResult { impl<'de> serde::Deserialize<'de> for ModelToolResult { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/replay_journal_record.rs b/runtime/rust/prompty/src/model/pipeline/replay_journal_record.rs index b61b9829a..cbff16e8d 100644 --- a/runtime/rust/prompty/src/model/pipeline/replay_journal_record.rs +++ b/runtime/rust/prompty/src/model/pipeline/replay_journal_record.rs @@ -189,12 +189,16 @@ impl ReplayJournalRecord { /// Load ReplayJournalRecord from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ReplayJournalRecord from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -203,6 +207,9 @@ impl ReplayJournalRecord { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { kind: value .get("kind") @@ -253,6 +260,10 @@ impl ReplayJournalRecord { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize ReplayJournalRecord to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -263,61 +274,61 @@ impl ReplayJournalRecord { "kind".to_string(), serde_json::Value::String(self.kind.to_string()), ); - if let Some(ref val) = self.r#type { + if let Some(val) = self.r#type.as_ref() { result.insert("type".to_string(), serde_json::Value::String(val.clone())); } - if let Some(ref val) = self.session_id { + if let Some(val) = self.session_id.as_ref() { result.insert( "sessionId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.turn_id { + if let Some(val) = self.turn_id.as_ref() { result.insert("turnId".to_string(), serde_json::Value::String(val.clone())); } - if let Some(val) = self.iteration { + if let Some(val) = self.iteration.as_ref() { result.insert( "iteration".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(ref val) = self.status { + if let Some(val) = self.status.as_ref() { result.insert( "status".to_string(), serde_json::Value::String(val.to_string()), ); } - if let Some(ref val) = self.request_id { + if let Some(val) = self.request_id.as_ref() { result.insert( "requestId".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(ref val) = self.tool_name { + if let Some(val) = self.tool_name.as_ref() { result.insert( "toolName".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(val) = self.success { - result.insert("success".to_string(), serde_json::Value::Bool(val)); + if let Some(val) = self.success.as_ref() { + result.insert("success".to_string(), serde_json::Value::Bool(*val)); } - if let Some(ref val) = self.error_kind { + if let Some(val) = self.error_kind.as_ref() { result.insert( "errorKind".to_string(), serde_json::Value::String(val.clone()), ); } - if let Some(val) = self.turns { + if let Some(val) = self.turns.as_ref() { result.insert( "turns".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.checkpoints { + if let Some(val) = self.checkpoints.as_ref() { result.insert( "checkpoints".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } ctx.process_dict(serde_json::Value::Object(result)) @@ -346,6 +357,7 @@ impl serde::Serialize for ReplayJournalRecord { impl<'de> serde::Deserialize<'de> for ReplayJournalRecord { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/replay_mismatch.rs b/runtime/rust/prompty/src/model/pipeline/replay_mismatch.rs index de08ae636..4514a507e 100644 --- a/runtime/rust/prompty/src/model/pipeline/replay_mismatch.rs +++ b/runtime/rust/prompty/src/model/pipeline/replay_mismatch.rs @@ -35,12 +35,16 @@ impl ReplayMismatch { /// Load ReplayMismatch from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ReplayMismatch from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -49,6 +53,9 @@ impl ReplayMismatch { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { index: value.get("index").and_then(|v| v.as_i64()).unwrap_or(0) as i32, expected: value @@ -67,6 +74,26 @@ impl ReplayMismatch { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "expected".to_string() + } else { + format!("{}.expected", path) + }; + if let Some(child) = value.get("expected") { + ReplayJournalRecord::validate_input_at(child, &child_path)?; + } + let child_path = if path.is_empty() { + "actual".to_string() + } else { + format!("{}.actual", path) + }; + if let Some(child) = value.get("actual") { + ReplayJournalRecord::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize ReplayMismatch to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -79,13 +106,13 @@ impl ReplayMismatch { serde_json::Value::Number(serde_json::Number::from(self.index)), ); } - if let Some(ref val) = self.expected { + if let Some(val) = self.expected.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("expected".to_string(), nested); } } - if let Some(ref val) = self.actual { + if let Some(val) = self.actual.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("actual".to_string(), nested); @@ -123,6 +150,7 @@ impl serde::Serialize for ReplayMismatch { impl<'de> serde::Deserialize<'de> for ReplayMismatch { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/replay_verification_request.rs b/runtime/rust/prompty/src/model/pipeline/replay_verification_request.rs index a411bd271..73ec66406 100644 --- a/runtime/rust/prompty/src/model/pipeline/replay_verification_request.rs +++ b/runtime/rust/prompty/src/model/pipeline/replay_verification_request.rs @@ -31,12 +31,16 @@ impl ReplayVerificationRequest { /// Load ReplayVerificationRequest from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ReplayVerificationRequest from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl ReplayVerificationRequest { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { expected: value .get("expected") @@ -57,21 +64,49 @@ impl ReplayVerificationRequest { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("expected") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "expected".to_string() + } else { + format!("{}.expected", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + ReplayJournalRecord::validate_input_at(entry, &entry_path)?; + } + } + if let Some(entries) = value + .get("actual") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "actual".to_string() + } else { + format!("{}.actual", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + ReplayJournalRecord::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize ReplayVerificationRequest to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if !self.expected.is_empty() { - result.insert( - "expected".to_string(), - Self::save_expected(&self.expected, ctx), - ); - } - if !self.actual.is_empty() { - result.insert("actual".to_string(), Self::save_actual(&self.actual, ctx)); - } + result.insert( + "expected".to_string(), + Self::save_expected(&self.expected, ctx), + ); + result.insert("actual".to_string(), Self::save_actual(&self.actual, ctx)); ctx.process_dict(serde_json::Value::Object(result)) } @@ -144,6 +179,7 @@ impl serde::Serialize for ReplayVerificationRequest { impl<'de> serde::Deserialize<'de> for ReplayVerificationRequest { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/replay_verification_result.rs b/runtime/rust/prompty/src/model/pipeline/replay_verification_result.rs index 2b637b0f8..76a3dac15 100644 --- a/runtime/rust/prompty/src/model/pipeline/replay_verification_result.rs +++ b/runtime/rust/prompty/src/model/pipeline/replay_verification_result.rs @@ -98,12 +98,16 @@ impl ReplayVerificationResult { /// Load ReplayVerificationResult from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ReplayVerificationResult from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -112,6 +116,9 @@ impl ReplayVerificationResult { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { status: value .get("status") @@ -133,6 +140,24 @@ impl ReplayVerificationResult { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("mismatches") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "mismatches".to_string() + } else { + format!("{}.mismatches", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + ReplayMismatch::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize ReplayVerificationResult to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -143,12 +168,10 @@ impl ReplayVerificationResult { "status".to_string(), serde_json::Value::String(self.status.to_string()), ); - if !self.mismatches.is_empty() { - result.insert( - "mismatches".to_string(), - Self::save_mismatches(&self.mismatches, ctx), - ); - } + result.insert( + "mismatches".to_string(), + Self::save_mismatches(&self.mismatches, ctx), + ); if self.expected_count != 0 { result.insert( "expectedCount".to_string(), @@ -210,6 +233,7 @@ impl serde::Serialize for ReplayVerificationResult { impl<'de> serde::Deserialize<'de> for ReplayVerificationResult { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/resume_context.rs b/runtime/rust/prompty/src/model/pipeline/resume_context.rs index 0e260e4d7..d97cb6220 100644 --- a/runtime/rust/prompty/src/model/pipeline/resume_context.rs +++ b/runtime/rust/prompty/src/model/pipeline/resume_context.rs @@ -37,12 +37,16 @@ impl ResumeContext { /// Load ResumeContext from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ResumeContext from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -51,6 +55,9 @@ impl ResumeContext { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { checkpoint: value .get("checkpoint") @@ -76,6 +83,20 @@ impl ResumeContext { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "checkpoint".to_string() + } else { + format!("{}.checkpoint", path) + }; + let child = value + .get("checkpoint") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + EngineCheckpoint::validate_input_at(child, &child_path)?; + Ok(()) + } + /// Serialize ResumeContext to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -140,6 +161,7 @@ impl serde::Serialize for ResumeContext { impl<'de> serde::Deserialize<'de> for ResumeContext { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/retry_policy_request.rs b/runtime/rust/prompty/src/model/pipeline/retry_policy_request.rs index 354345ce7..4bd491aa9 100644 --- a/runtime/rust/prompty/src/model/pipeline/retry_policy_request.rs +++ b/runtime/rust/prompty/src/model/pipeline/retry_policy_request.rs @@ -33,12 +33,16 @@ impl RetryPolicyRequest { /// Load RetryPolicyRequest from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load RetryPolicyRequest from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -47,6 +51,9 @@ impl RetryPolicyRequest { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { failed_attempts: value .get("failedAttempts") @@ -68,6 +75,10 @@ impl RetryPolicyRequest { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize RetryPolicyRequest to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -124,6 +135,7 @@ impl serde::Serialize for RetryPolicyRequest { impl<'de> serde::Deserialize<'de> for RetryPolicyRequest { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/run_turn_request.rs b/runtime/rust/prompty/src/model/pipeline/run_turn_request.rs index 38e333c2d..7d8bdfc11 100644 --- a/runtime/rust/prompty/src/model/pipeline/run_turn_request.rs +++ b/runtime/rust/prompty/src/model/pipeline/run_turn_request.rs @@ -20,7 +20,7 @@ pub struct RunTurnRequest { pub session_id: String, /// Stable turn identifier within the session pub turn_id: String, - /// Inputs supplied to the deterministic single-turn run + /// Inputs supplied to the deterministic single-turn run. Values may be explicit null. pub inputs: serde_json::Value, /// Canonical turn execution options pub options: Option, @@ -35,12 +35,16 @@ impl RunTurnRequest { /// Load RunTurnRequest from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load RunTurnRequest from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -49,6 +53,9 @@ impl RunTurnRequest { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { session_id: value .get("sessionId") @@ -71,6 +78,18 @@ impl RunTurnRequest { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "options".to_string() + } else { + format!("{}.options", path) + }; + if let Some(child) = value.get("options") { + TurnOptions::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize RunTurnRequest to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -92,7 +111,7 @@ impl RunTurnRequest { if !self.inputs.is_null() { result.insert("inputs".to_string(), self.inputs.clone()); } - if let Some(ref val) = self.options { + if let Some(val) = self.options.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("options".to_string(), nested); @@ -129,6 +148,7 @@ impl serde::Serialize for RunTurnRequest { impl<'de> serde::Deserialize<'de> for RunTurnRequest { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/run_turn_result.rs b/runtime/rust/prompty/src/model/pipeline/run_turn_result.rs index 21e0d0acf..fa253702d 100644 --- a/runtime/rust/prompty/src/model/pipeline/run_turn_result.rs +++ b/runtime/rust/prompty/src/model/pipeline/run_turn_result.rs @@ -112,12 +112,16 @@ impl RunTurnResult { /// Load RunTurnResult from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load RunTurnResult from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -126,6 +130,9 @@ impl RunTurnResult { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { session_id: value .get("sessionId") @@ -158,6 +165,38 @@ impl RunTurnResult { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("toolResults") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "toolResults".to_string() + } else { + format!("{}.toolResults", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + HostToolResult::validate_input_at(entry, &entry_path)?; + } + } + if let Some(entries) = value + .get("checkpoints") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "checkpoints".to_string() + } else { + format!("{}.checkpoints", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Checkpoint::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize RunTurnResult to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -180,7 +219,7 @@ impl RunTurnResult { "status".to_string(), serde_json::Value::String(self.status.to_string()), ); - if let Some(ref val) = self.output { + if let Some(val) = self.output.as_ref() { result.insert("output".to_string(), val.clone()); } if self.iterations != 0 { @@ -189,18 +228,14 @@ impl RunTurnResult { serde_json::Value::Number(serde_json::Number::from(self.iterations)), ); } - if !self.tool_results.is_empty() { - result.insert( - "toolResults".to_string(), - Self::save_tool_results(&self.tool_results, ctx), - ); - } - if !self.checkpoints.is_empty() { - result.insert( - "checkpoints".to_string(), - Self::save_checkpoints(&self.checkpoints, ctx), - ); - } + result.insert( + "toolResults".to_string(), + Self::save_tool_results(&self.tool_results, ctx), + ); + result.insert( + "checkpoints".to_string(), + Self::save_checkpoints(&self.checkpoints, ctx), + ); ctx.process_dict(serde_json::Value::Object(result)) } @@ -273,6 +308,7 @@ impl serde::Serialize for RunTurnResult { impl<'de> serde::Deserialize<'de> for RunTurnResult { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/turn_commit.rs b/runtime/rust/prompty/src/model/pipeline/turn_commit.rs index 08af4c644..f038aa226 100644 --- a/runtime/rust/prompty/src/model/pipeline/turn_commit.rs +++ b/runtime/rust/prompty/src/model/pipeline/turn_commit.rs @@ -126,12 +126,16 @@ impl TurnCommit { /// Load TurnCommit from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TurnCommit from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -140,6 +144,9 @@ impl TurnCommit { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { session_id: value .get("sessionId") @@ -181,6 +188,42 @@ impl TurnCommit { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("messages") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "messages".to_string() + } else { + format!("{}.messages", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Message::validate_input_at(entry, &entry_path)?; + } + } + let child_path = if path.is_empty() { + "contextState".to_string() + } else { + format!("{}.contextState", path) + }; + let child = value + .get("contextState") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + InvocationContextState::validate_input_at(child, &child_path)?; + let child_path = if path.is_empty() { + "modelReconciliation".to_string() + } else { + format!("{}.modelReconciliation", path) + }; + if let Some(child) = value.get("modelReconciliation") { + ModelReconciliationState::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize TurnCommit to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -203,15 +246,13 @@ impl TurnCommit { "status".to_string(), serde_json::Value::String(self.status.to_string()), ); - if let Some(ref val) = self.output { + if let Some(val) = self.output.as_ref() { result.insert("output".to_string(), val.clone()); } - if !self.messages.is_empty() { - result.insert( - "messages".to_string(), - Self::save_messages(&self.messages, ctx), - ); - } + result.insert( + "messages".to_string(), + Self::save_messages(&self.messages, ctx), + ); if self.iterations != 0 { result.insert( "iterations".to_string(), @@ -230,7 +271,7 @@ impl TurnCommit { result.insert("contextState".to_string(), nested); } } - if let Some(ref val) = self.model_reconciliation { + if let Some(val) = self.model_reconciliation.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("modelReconciliation".to_string(), nested); @@ -285,6 +326,7 @@ impl serde::Serialize for TurnCommit { impl<'de> serde::Deserialize<'de> for TurnCommit { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/turn_engine_result.rs b/runtime/rust/prompty/src/model/pipeline/turn_engine_result.rs index cad02591f..65144a7c8 100644 --- a/runtime/rust/prompty/src/model/pipeline/turn_engine_result.rs +++ b/runtime/rust/prompty/src/model/pipeline/turn_engine_result.rs @@ -39,12 +39,16 @@ impl TurnEngineResult { /// Load TurnEngineResult from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TurnEngineResult from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -53,6 +57,9 @@ impl TurnEngineResult { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { commit: value .get("commit") @@ -74,6 +81,48 @@ impl TurnEngineResult { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "commit".to_string() + } else { + format!("{}.commit", path) + }; + let child = value + .get("commit") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + TurnCommit::validate_input_at(child, &child_path)?; + if let Some(entries) = value + .get("snapshots") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "snapshots".to_string() + } else { + format!("{}.snapshots", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + ModelInvocationContextSnapshot::validate_input_at(entry, &entry_path)?; + } + } + if let Some(entries) = value + .get("toolResults") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "toolResults".to_string() + } else { + format!("{}.toolResults", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + ModelToolResult::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize TurnEngineResult to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -86,19 +135,15 @@ impl TurnEngineResult { result.insert("commit".to_string(), nested); } } - if !self.snapshots.is_empty() { - result.insert( - "snapshots".to_string(), - Self::save_snapshots(&self.snapshots, ctx), - ); - } - if !self.tool_results.is_empty() { - result.insert( - "toolResults".to_string(), - Self::save_tool_results(&self.tool_results, ctx), - ); - } - if let Some(ref val) = self.post_commit_error { + result.insert( + "snapshots".to_string(), + Self::save_snapshots(&self.snapshots, ctx), + ); + result.insert( + "toolResults".to_string(), + Self::save_tool_results(&self.tool_results, ctx), + ); + if let Some(val) = self.post_commit_error.as_ref() { result.insert( "postCommitError".to_string(), serde_json::Value::String(val.clone()), @@ -147,7 +192,7 @@ impl TurnEngineResult { } /// Load a collection of ModelToolResult from a JSON value. - /// Handles both array format `[{...}]` and dict format `{"name": {...}}`. + /// Handles both array format `[{...}]`. fn load_tool_results(data: &serde_json::Value, ctx: &LoadContext) -> Vec { match data { serde_json::Value::Array(arr) => arr @@ -155,54 +200,18 @@ impl TurnEngineResult { .map(|v| ModelToolResult::load_from_value(v, ctx)) .collect(), - serde_json::Value::Object(obj) => obj - .iter() - .filter_map(|(name, value)| { - if value.is_array() { - return None; - } - let mut v = if value.is_object() { - value.clone() - } else { - serde_json::json!({ "requestId": value }) - }; - if let serde_json::Value::Object(ref mut m) = v { - m.entry("name".to_string()) - .or_insert_with(|| serde_json::Value::String(name.clone())); - } - Some(ModelToolResult::load_from_value(&v, ctx)) - }) - .collect(), _ => Vec::new(), } } /// Save a collection of ModelToolResult to a JSON value. fn save_tool_results(items: &[ModelToolResult], ctx: &SaveContext) -> serde_json::Value { - if ctx.collection_format == "array" { - return serde_json::Value::Array( - items - .iter() - .map(|item| item.to_value(ctx)) - .collect::>(), - ); - } - // Object format: use name as key - let mut result = serde_json::Map::new(); - for item in items { - let mut item_data = match item.to_value(ctx) { - serde_json::Value::Object(m) => m, - other => { - let mut m = serde_json::Map::new(); - m.insert("value".to_string(), other); - m - } - }; - if let Some(serde_json::Value::String(name)) = item_data.remove("name") { - result.insert(name, serde_json::Value::Object(item_data)); - } - } - serde_json::Value::Object(result) + serde_json::Value::Array( + items + .iter() + .map(|item| item.to_value(ctx)) + .collect::>(), + ) } } @@ -218,6 +227,7 @@ impl serde::Serialize for TurnEngineResult { impl<'de> serde::Deserialize<'de> for TurnEngineResult { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/turn_model_request.rs b/runtime/rust/prompty/src/model/pipeline/turn_model_request.rs index 1fc54d08e..f178727cc 100644 --- a/runtime/rust/prompty/src/model/pipeline/turn_model_request.rs +++ b/runtime/rust/prompty/src/model/pipeline/turn_model_request.rs @@ -24,7 +24,7 @@ pub struct TurnModelRequest { pub turn_id: String, /// Zero-based model loop iteration pub iteration: i32, - /// Inputs supplied to the deterministic single-turn run + /// Inputs supplied to the deterministic single-turn run. Values may be explicit null. pub inputs: serde_json::Value, /// Canonical turn execution options pub options: Option, @@ -41,12 +41,16 @@ impl TurnModelRequest { /// Load TurnModelRequest from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TurnModelRequest from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -55,6 +59,9 @@ impl TurnModelRequest { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { session_id: value .get("sessionId") @@ -82,6 +89,32 @@ impl TurnModelRequest { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "options".to_string() + } else { + format!("{}.options", path) + }; + if let Some(child) = value.get("options") { + TurnOptions::validate_input_at(child, &child_path)?; + } + if let Some(entries) = value + .get("toolResults") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "toolResults".to_string() + } else { + format!("{}.toolResults", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + HostToolResult::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize TurnModelRequest to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -109,18 +142,16 @@ impl TurnModelRequest { if !self.inputs.is_null() { result.insert("inputs".to_string(), self.inputs.clone()); } - if let Some(ref val) = self.options { + if let Some(val) = self.options.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("options".to_string(), nested); } } - if !self.tool_results.is_empty() { - result.insert( - "toolResults".to_string(), - Self::save_tool_results(&self.tool_results, ctx), - ); - } + result.insert( + "toolResults".to_string(), + Self::save_tool_results(&self.tool_results, ctx), + ); ctx.process_dict(serde_json::Value::Object(result)) } @@ -175,6 +206,7 @@ impl serde::Serialize for TurnModelRequest { impl<'de> serde::Deserialize<'de> for TurnModelRequest { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/turn_model_response.rs b/runtime/rust/prompty/src/model/pipeline/turn_model_response.rs index f561bed4e..0447d2d58 100644 --- a/runtime/rust/prompty/src/model/pipeline/turn_model_response.rs +++ b/runtime/rust/prompty/src/model/pipeline/turn_model_response.rs @@ -24,7 +24,7 @@ pub struct TurnModelResponse { pub usage: Option, /// Host tool execution requests emitted by the model callback pub tool_requests: Vec, - /// Additional deterministic state to merge into the iteration checkpoint + /// Additional deterministic state to merge into the iteration checkpoint. Values may be explicit null. pub checkpoint_state: serde_json::Value, } @@ -37,12 +37,16 @@ impl TurnModelResponse { /// Load TurnModelResponse from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TurnModelResponse from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -51,6 +55,9 @@ impl TurnModelResponse { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { output: value.get("output").cloned(), usage: value @@ -68,27 +75,51 @@ impl TurnModelResponse { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "usage".to_string() + } else { + format!("{}.usage", path) + }; + if let Some(child) = value.get("usage") { + InvocationUsage::validate_input_at(child, &child_path)?; + } + if let Some(entries) = value + .get("toolRequests") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "toolRequests".to_string() + } else { + format!("{}.toolRequests", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + HostToolRequest::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize TurnModelResponse to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(ref val) = self.output { + if let Some(val) = self.output.as_ref() { result.insert("output".to_string(), val.clone()); } - if let Some(ref val) = self.usage { + if let Some(val) = self.usage.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("usage".to_string(), nested); } } - if !self.tool_requests.is_empty() { - result.insert( - "toolRequests".to_string(), - Self::save_tool_requests(&self.tool_requests, ctx), - ); - } + result.insert( + "toolRequests".to_string(), + Self::save_tool_requests(&self.tool_requests, ctx), + ); if !self.checkpoint_state.is_null() { result.insert("checkpointState".to_string(), self.checkpoint_state.clone()); } @@ -146,6 +177,7 @@ impl serde::Serialize for TurnModelResponse { impl<'de> serde::Deserialize<'de> for TurnModelResponse { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/pipeline/turn_options.rs b/runtime/rust/prompty/src/model/pipeline/turn_options.rs index 40d891424..cd0f32e09 100644 --- a/runtime/rust/prompty/src/model/pipeline/turn_options.rs +++ b/runtime/rust/prompty/src/model/pipeline/turn_options.rs @@ -41,12 +41,16 @@ impl TurnOptions { /// Load TurnOptions from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TurnOptions from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -55,6 +59,9 @@ impl TurnOptions { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { max_iterations: value .get("maxIterations") @@ -78,46 +85,58 @@ impl TurnOptions { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "compaction".to_string() + } else { + format!("{}.compaction", path) + }; + if let Some(child) = value.get("compaction") { + CompactionConfig::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize TurnOptions to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(val) = self.max_iterations { + if let Some(val) = self.max_iterations.as_ref() { result.insert( "maxIterations".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.max_llm_retries { + if let Some(val) = self.max_llm_retries.as_ref() { result.insert( "maxLlmRetries".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.context_budget { + if let Some(val) = self.context_budget.as_ref() { result.insert( "contextBudget".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(val) = self.parallel_tool_calls { + if let Some(val) = self.parallel_tool_calls.as_ref() { result.insert( "parallelToolCalls".to_string(), - serde_json::Value::Bool(val), + serde_json::Value::Bool(*val), ); } - if let Some(val) = self.raw { - result.insert("raw".to_string(), serde_json::Value::Bool(val)); + if let Some(val) = self.raw.as_ref() { + result.insert("raw".to_string(), serde_json::Value::Bool(*val)); } - if let Some(val) = self.turn { + if let Some(val) = self.turn.as_ref() { result.insert( "turn".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(ref val) = self.compaction { + if let Some(val) = self.compaction.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("compaction".to_string(), nested); @@ -149,6 +168,7 @@ impl serde::Serialize for TurnOptions { impl<'de> serde::Deserialize<'de> for TurnOptions { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/streaming/stream_options.rs b/runtime/rust/prompty/src/model/streaming/stream_options.rs index 2e2e47fff..d53ba15f0 100644 --- a/runtime/rust/prompty/src/model/streaming/stream_options.rs +++ b/runtime/rust/prompty/src/model/streaming/stream_options.rs @@ -27,12 +27,16 @@ impl StreamOptions { /// Load StreamOptions from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load StreamOptions from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -41,19 +45,26 @@ impl StreamOptions { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { include_usage: value.get("includeUsage").and_then(|v| v.as_bool()), } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize StreamOptions to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. pub fn to_value(&self, ctx: &SaveContext) -> serde_json::Value { let mut result = serde_json::Map::new(); // Write base fields - if let Some(val) = self.include_usage { - result.insert("includeUsage".to_string(), serde_json::Value::Bool(val)); + if let Some(val) = self.include_usage.as_ref() { + result.insert("includeUsage".to_string(), serde_json::Value::Bool(*val)); } ctx.process_dict(serde_json::Value::Object(result)) } @@ -81,6 +92,7 @@ impl serde::Serialize for StreamOptions { impl<'de> serde::Deserialize<'de> for StreamOptions { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/template/format_config.rs b/runtime/rust/prompty/src/model/template/format_config.rs index d090632c0..98f91596d 100644 --- a/runtime/rust/prompty/src/model/template/format_config.rs +++ b/runtime/rust/prompty/src/model/template/format_config.rs @@ -31,12 +31,16 @@ impl FormatConfig { /// Load FormatConfig from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load FormatConfig from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl FormatConfig { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } if let Some(s) = value.as_str() { let value = s.to_string(); return FormatConfig { @@ -66,6 +73,10 @@ impl FormatConfig { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize FormatConfig to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -78,8 +89,8 @@ impl FormatConfig { serde_json::Value::String(self.kind.clone()), ); } - if let Some(val) = self.strict { - result.insert("strict".to_string(), serde_json::Value::Bool(val)); + if let Some(val) = self.strict.as_ref() { + result.insert("strict".to_string(), serde_json::Value::Bool(*val)); } if !self.options.is_null() { result.insert("options".to_string(), self.options.clone()); @@ -115,6 +126,7 @@ impl serde::Serialize for FormatConfig { impl<'de> serde::Deserialize<'de> for FormatConfig { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/template/parser_config.rs b/runtime/rust/prompty/src/model/template/parser_config.rs index 7c2ac7b2e..1ac4897de 100644 --- a/runtime/rust/prompty/src/model/template/parser_config.rs +++ b/runtime/rust/prompty/src/model/template/parser_config.rs @@ -29,12 +29,16 @@ impl ParserConfig { /// Load ParserConfig from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ParserConfig from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -43,6 +47,9 @@ impl ParserConfig { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } if let Some(s) = value.as_str() { let value = s.to_string(); return ParserConfig { @@ -63,6 +70,10 @@ impl ParserConfig { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize ParserConfig to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -109,6 +120,7 @@ impl serde::Serialize for ParserConfig { impl<'de> serde::Deserialize<'de> for ParserConfig { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/template/template.rs b/runtime/rust/prompty/src/model/template/template.rs index 1070ec02b..24dd4b001 100644 --- a/runtime/rust/prompty/src/model/template/template.rs +++ b/runtime/rust/prompty/src/model/template/template.rs @@ -33,12 +33,16 @@ impl Template { /// Load Template from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load Template from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -47,6 +51,9 @@ impl Template { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { format: value .get("format") @@ -61,6 +68,30 @@ impl Template { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "format".to_string() + } else { + format!("{}.format", path) + }; + let child = value + .get("format") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + FormatConfig::validate_input_at(child, &child_path)?; + let child_path = if path.is_empty() { + "parser".to_string() + } else { + format!("{}.parser", path) + }; + let child = value + .get("parser") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + ParserConfig::validate_input_at(child, &child_path)?; + Ok(()) + } + /// Serialize Template to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -105,6 +136,7 @@ impl serde::Serialize for Template { impl<'de> serde::Deserialize<'de> for Template { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/tools/binding.rs b/runtime/rust/prompty/src/model/tools/binding.rs index 89e8706d6..d083eb58e 100644 --- a/runtime/rust/prompty/src/model/tools/binding.rs +++ b/runtime/rust/prompty/src/model/tools/binding.rs @@ -29,12 +29,16 @@ impl Binding { /// Load Binding from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load Binding from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -43,6 +47,9 @@ impl Binding { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } if let Some(s) = value.as_str() { let value = s.to_string(); return Binding { @@ -64,6 +71,10 @@ impl Binding { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize Binding to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -108,6 +119,7 @@ impl serde::Serialize for Binding { impl<'de> serde::Deserialize<'de> for Binding { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/tools/mcp_approval_mode.rs b/runtime/rust/prompty/src/model/tools/mcp_approval_mode.rs index 83ecdd162..66dc68fd2 100644 --- a/runtime/rust/prompty/src/model/tools/mcp_approval_mode.rs +++ b/runtime/rust/prompty/src/model/tools/mcp_approval_mode.rs @@ -101,12 +101,16 @@ impl McpApprovalMode { /// Load McpApprovalMode from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load McpApprovalMode from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -115,6 +119,9 @@ impl McpApprovalMode { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } if let Some(s) = value.as_str() { let value = s.to_string(); return McpApprovalMode { @@ -148,6 +155,10 @@ impl McpApprovalMode { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize McpApprovalMode to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -158,13 +169,13 @@ impl McpApprovalMode { "kind".to_string(), serde_json::Value::String(self.kind.to_string()), ); - if let Some(ref items) = self.always_require_approval_tools { + if let Some(items) = self.always_require_approval_tools.as_ref() { result.insert( "alwaysRequireApprovalTools".to_string(), serde_json::to_value(items).unwrap_or(serde_json::Value::Null), ); } - if let Some(ref items) = self.never_require_approval_tools { + if let Some(items) = self.never_require_approval_tools.as_ref() { result.insert( "neverRequireApprovalTools".to_string(), serde_json::to_value(items).unwrap_or(serde_json::Value::Null), @@ -196,6 +207,7 @@ impl serde::Serialize for McpApprovalMode { impl<'de> serde::Deserialize<'de> for McpApprovalMode { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/tools/tool.rs b/runtime/rust/prompty/src/model/tools/tool.rs index cf969bd7b..0f1973b2d 100644 --- a/runtime/rust/prompty/src/model/tools/tool.rs +++ b/runtime/rust/prompty/src/model/tools/tool.rs @@ -38,7 +38,7 @@ pub enum ToolKind { /// The description of the MCP tool server_description: Option, /// The approval mode for the MCP tool - approval_mode: McpApprovalMode, + approval_mode: Option, /// List of allowed operations or resources for the MCP tool allowed_tools: Option>, }, @@ -98,12 +98,16 @@ impl Tool { /// Load Tool from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load Tool from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -112,6 +116,9 @@ impl Tool { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } let kind_str = value.get("kind").and_then(|v| v.as_str()).unwrap_or(""); let kind = match kind_str { "function" => ToolKind::Function { @@ -138,8 +145,7 @@ impl Tool { approval_mode: value .get("approvalMode") .filter(|v| v.is_object() || v.is_array() || v.is_string()) - .map(|v| McpApprovalMode::load_from_value(v, ctx)) - .unwrap_or_default(), + .map(|v| McpApprovalMode::load_from_value(v, ctx)), allowed_tools: value .get("allowedTools") .and_then(|v| v.as_array()) @@ -202,6 +208,144 @@ impl Tool { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(collection) = value.get("bindings") { + let collection_path = if path.is_empty() { + "bindings".to_string() + } else { + format!("{}.bindings", path) + }; + match collection { + serde_json::Value::Object(entries) => { + for (name, entry) in entries { + let entry_path = format!("{}.{}", collection_path, name); + if entry.is_array() { + return Err(format!( + "{}: invalid named collection entry category array", + entry_path + )); + } + let mut candidate = if entry.is_object() { + entry.clone() + } else { + serde_json::json!({ "input": entry }) + }; + if let serde_json::Value::Object(ref mut map) = candidate { + map.insert("name".to_string(), serde_json::Value::String(name.clone())); + } + Binding::validate_input_at(&candidate, &entry_path)?; + } + } + serde_json::Value::Array(entries) => { + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Binding::validate_input_at(entry, &entry_path)?; + } + } + _ => {} + } + } + match value + .get("kind") + .and_then(|candidate| candidate.as_str()) + .unwrap_or("") + { + "function" => { + if let Some(collection) = value.get("parameters") { + let collection_path = if path.is_empty() { + "parameters".to_string() + } else { + format!("{}.parameters", path) + }; + match collection { + serde_json::Value::Object(entries) => { + for (name, entry) in entries { + let entry_path = format!("{}.{}", collection_path, name); + if entry.is_array() { + return Err(format!( + "{}: invalid named collection entry category array", + entry_path + )); + } + let mut candidate = if entry.is_object() { + entry.clone() + } else { + serde_json::json!({ "kind": entry }) + }; + if let serde_json::Value::Object(ref mut map) = candidate { + map.insert( + "name".to_string(), + serde_json::Value::String(name.clone()), + ); + } + Property::validate_input_at(&candidate, &entry_path)?; + } + } + serde_json::Value::Array(entries) => { + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Property::validate_input_at(entry, &entry_path)?; + } + } + _ => {} + } + } + } + "mcp" => { + let child_path = if path.is_empty() { + "connection".to_string() + } else { + format!("{}.connection", path) + }; + let child = value + .get("connection") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + Connection::validate_input_at(child, &child_path)?; + let child_path = if path.is_empty() { + "approvalMode".to_string() + } else { + format!("{}.approvalMode", path) + }; + if let Some(child) = value.get("approvalMode") { + McpApprovalMode::validate_input_at(child, &child_path)?; + } + } + "openapi" => { + let child_path = if path.is_empty() { + "connection".to_string() + } else { + format!("{}.connection", path) + }; + let child = value + .get("connection") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + Connection::validate_input_at(child, &child_path)?; + } + "prompty" => {} + _ => { + if value + .get("kind") + .and_then(|candidate| candidate.as_str()) + .is_some_and(|discriminator| !discriminator.is_empty()) + { + let child_path = if path.is_empty() { + "connection".to_string() + } else { + format!("{}.connection", path) + }; + let child = value + .get("connection") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + Connection::validate_input_at(child, &child_path)?; + } + } + } + Ok(()) + } + /// Returns the `kind` discriminator string for this instance. pub fn kind_str(&self) -> &str { match &self.kind { @@ -230,18 +374,16 @@ impl Tool { serde_json::Value::String(self.name.clone()), ); } - if let Some(ref val) = self.description { + if let Some(val) = self.description.as_ref() { result.insert( "description".to_string(), serde_json::Value::String(val.clone()), ); } - if !self.bindings.is_empty() { - result.insert( - "bindings".to_string(), - Self::save_bindings(&self.bindings, ctx), - ); - } + result.insert( + "bindings".to_string(), + Self::save_bindings(&self.bindings, ctx), + ); // Write variant-specific fields match &self.kind { ToolKind::Function { @@ -250,9 +392,7 @@ impl Tool { if !parameters.is_empty() { result.insert( "parameters".to_string(), - serde_json::Value::Array( - parameters.iter().map(|item| item.to_value(ctx)).collect(), - ), + Self::save_parameters(parameters, ctx), ); } if let Some(val) = strict { @@ -282,13 +422,10 @@ impl Tool { serde_json::Value::String(val.clone()), ); } - { - let nested = approval_mode.to_value(ctx); - if !nested.is_null() { - result.insert("approvalMode".to_string(), nested); - } + if let Some(val) = approval_mode { + result.insert("approvalMode".to_string(), val.to_value(ctx)); } - if let Some(items) = allowed_tools { + if let Some(items) = allowed_tools.as_ref() { result.insert( "allowedTools".to_string(), serde_json::to_value(items).unwrap_or(serde_json::Value::Null), @@ -356,9 +493,12 @@ impl Tool { serde_json::Value::Object(obj) => obj .iter() - .filter_map(|(name, value)| { + .map(|(name, value)| { if value.is_array() { - return None; + panic!( + "bindings.{}: invalid named collection entry category array", + name + ); } let mut v = if value.is_object() { value.clone() @@ -369,7 +509,7 @@ impl Tool { m.entry("name".to_string()) .or_insert_with(|| serde_json::Value::String(name.clone())); } - Some(Binding::load_from_value(&v, ctx)) + Binding::load_from_value(&v, ctx) }) .collect(), _ => Vec::new(), @@ -378,18 +518,35 @@ impl Tool { /// Save a collection of Binding to a JSON value. fn save_bindings(items: &[Binding], ctx: &SaveContext) -> serde_json::Value { + let mut serialized = items + .iter() + .map(|item| item.to_value(ctx)) + .collect::>(); + for item_data in &mut serialized { + if let serde_json::Value::Object(map) = item_data { + if matches!(map.get("name"), Some(serde_json::Value::String(name)) if name.is_empty()) + { + map.remove("name"); + } + } + } + if ctx.collection_format == "array" { - return serde_json::Value::Array( - items - .iter() - .map(|item| item.to_value(ctx)) - .collect::>(), - ); + return serde_json::Value::Array(serialized); + } + let mut names = std::collections::HashSet::new(); + for item_data in &serialized { + let Some(name) = item_data.get("name").and_then(|value| value.as_str()) else { + return serde_json::Value::Array(serialized); + }; + if name.is_empty() || !names.insert(name.to_string()) { + return serde_json::Value::Array(serialized); + } } // Object format: use name as key let mut result = serde_json::Map::new(); - for item in items { - let mut item_data = match item.to_value(ctx) { + for item_data in serialized { + let mut item_data = match item_data { serde_json::Value::Object(m) => m, other => { let mut m = serde_json::Map::new(); @@ -397,9 +554,19 @@ impl Tool { m } }; - if let Some(serde_json::Value::String(name)) = item_data.remove("name") { - result.insert(name, serde_json::Value::Object(item_data)); + let serde_json::Value::String(name) = item_data + .remove("name") + .expect("validated named collection item") + else { + unreachable!() + }; + if ctx.use_shorthand && item_data.len() == 1 { + if let Some(shorthand) = item_data.get("input") { + result.insert(name, shorthand.clone()); + continue; + } } + result.insert(name, serde_json::Value::Object(item_data)); } serde_json::Value::Object(result) } @@ -415,20 +582,31 @@ impl Tool { serde_json::Value::Object(obj) => obj .iter() - .filter_map(|(name, value)| { + .map(|(name, value)| { if value.is_array() { - return None; + panic!( + "parameters.{}: invalid named collection entry category array", + name + ); } let mut v = if value.is_object() { value.clone() + } else if value.is_i64() { + serde_json::json!({ "kind": "integer", "default": value }) + } else if value.is_f64() { + serde_json::json!({ "kind": "float", "default": value }) + } else if value.is_string() { + serde_json::json!({ "kind": "string", "default": value }) + } else if value.is_boolean() { + serde_json::json!({ "kind": "boolean", "default": value }) } else { - serde_json::json!({ "kind": value }) + serde_json::json!({ "default": value }) }; if let serde_json::Value::Object(ref mut m) = v { m.entry("name".to_string()) .or_insert_with(|| serde_json::Value::String(name.clone())); } - Some(Property::load_from_value(&v, ctx)) + Property::load_from_value(&v, ctx) }) .collect(), _ => Vec::new(), @@ -437,18 +615,35 @@ impl Tool { /// Save a collection of Property to a JSON value. fn save_parameters(items: &[Property], ctx: &SaveContext) -> serde_json::Value { + let mut serialized = items + .iter() + .map(|item| item.to_value(ctx)) + .collect::>(); + for item_data in &mut serialized { + if let serde_json::Value::Object(map) = item_data { + if matches!(map.get("name"), Some(serde_json::Value::String(name)) if name.is_empty()) + { + map.remove("name"); + } + } + } + if ctx.collection_format == "array" { - return serde_json::Value::Array( - items - .iter() - .map(|item| item.to_value(ctx)) - .collect::>(), - ); + return serde_json::Value::Array(serialized); + } + let mut names = std::collections::HashSet::new(); + for item_data in &serialized { + let Some(name) = item_data.get("name").and_then(|value| value.as_str()) else { + return serde_json::Value::Array(serialized); + }; + if name.is_empty() || !names.insert(name.to_string()) { + return serde_json::Value::Array(serialized); + } } // Object format: use name as key let mut result = serde_json::Map::new(); - for item in items { - let mut item_data = match item.to_value(ctx) { + for item_data in serialized { + let mut item_data = match item_data { serde_json::Value::Object(m) => m, other => { let mut m = serde_json::Map::new(); @@ -456,9 +651,19 @@ impl Tool { m } }; - if let Some(serde_json::Value::String(name)) = item_data.remove("name") { - result.insert(name, serde_json::Value::Object(item_data)); + let serde_json::Value::String(name) = item_data + .remove("name") + .expect("validated named collection item") + else { + unreachable!() + }; + if ctx.use_shorthand && item_data.len() == 1 { + if let Some(shorthand) = item_data.get("example") { + result.insert(name, shorthand.clone()); + continue; + } } + result.insert(name, serde_json::Value::Object(item_data)); } serde_json::Value::Object(result) } @@ -476,6 +681,7 @@ impl serde::Serialize for Tool { impl<'de> serde::Deserialize<'de> for Tool { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } @@ -497,6 +703,7 @@ impl serde::Serialize for ToolKind { impl<'de> serde::Deserialize<'de> for ToolKind { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Tool::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Tool::load_from_value(&value, &LoadContext::default()).kind) } } diff --git a/runtime/rust/prompty/src/model/tools/tool_context.rs b/runtime/rust/prompty/src/model/tools/tool_context.rs index dde7ef524..07fb63d09 100644 --- a/runtime/rust/prompty/src/model/tools/tool_context.rs +++ b/runtime/rust/prompty/src/model/tools/tool_context.rs @@ -31,12 +31,16 @@ impl ToolContext { /// Load ToolContext from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ToolContext from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl ToolContext { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { messages: value .get("messages") @@ -57,6 +64,24 @@ impl ToolContext { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("messages") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "messages".to_string() + } else { + format!("{}.messages", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + Message::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize ToolContext to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -126,6 +151,7 @@ impl serde::Serialize for ToolContext { impl<'de> serde::Deserialize<'de> for ToolContext { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/tools/tool_dispatch_result.rs b/runtime/rust/prompty/src/model/tools/tool_dispatch_result.rs index e35891476..170dd90ff 100644 --- a/runtime/rust/prompty/src/model/tools/tool_dispatch_result.rs +++ b/runtime/rust/prompty/src/model/tools/tool_dispatch_result.rs @@ -33,12 +33,16 @@ impl ToolDispatchResult { /// Load ToolDispatchResult from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load ToolDispatchResult from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -47,6 +51,9 @@ impl ToolDispatchResult { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { tool_call_id: value .get("toolCallId") @@ -66,6 +73,20 @@ impl ToolDispatchResult { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "result".to_string() + } else { + format!("{}.result", path) + }; + let child = value + .get("result") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + ToolResult::validate_input_at(child, &child_path)?; + Ok(()) + } + /// Serialize ToolDispatchResult to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -116,6 +137,7 @@ impl serde::Serialize for ToolDispatchResult { impl<'de> serde::Deserialize<'de> for ToolDispatchResult { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/tracing/trace_file.rs b/runtime/rust/prompty/src/model/tracing/trace_file.rs index ed043af65..a9fc4ec1c 100644 --- a/runtime/rust/prompty/src/model/tracing/trace_file.rs +++ b/runtime/rust/prompty/src/model/tracing/trace_file.rs @@ -33,12 +33,16 @@ impl TraceFile { /// Load TraceFile from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TraceFile from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -47,6 +51,9 @@ impl TraceFile { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { runtime: value .get("runtime") @@ -66,6 +73,20 @@ impl TraceFile { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "trace".to_string() + } else { + format!("{}.trace", path) + }; + let child = value + .get("trace") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + TraceSpan::validate_input_at(child, &child_path)?; + Ok(()) + } + /// Serialize TraceFile to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -116,6 +137,7 @@ impl serde::Serialize for TraceFile { impl<'de> serde::Deserialize<'de> for TraceFile { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/tracing/trace_span.rs b/runtime/rust/prompty/src/model/tracing/trace_span.rs index f827a6e34..915d79a33 100644 --- a/runtime/rust/prompty/src/model/tracing/trace_span.rs +++ b/runtime/rust/prompty/src/model/tracing/trace_span.rs @@ -47,12 +47,16 @@ impl TraceSpan { /// Load TraceSpan from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TraceSpan from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -61,6 +65,9 @@ impl TraceSpan { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { name: value .get("name") @@ -100,6 +107,28 @@ impl TraceSpan { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "__time".to_string() + } else { + format!("{}.__time", path) + }; + let child = value + .get("__time") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + TraceTime::validate_input_at(child, &child_path)?; + let child_path = if path.is_empty() { + "__usage".to_string() + } else { + format!("{}.__usage", path) + }; + if let Some(child) = value.get("__usage") { + TokenUsage::validate_input_at(child, &child_path)?; + } + Ok(()) + } + /// Serialize TraceSpan to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -118,7 +147,7 @@ impl TraceSpan { result.insert("__time".to_string(), nested); } } - if let Some(ref val) = self.signature { + if let Some(val) = self.signature.as_ref() { result.insert( "signature".to_string(), serde_json::Value::String(val.clone()), @@ -127,13 +156,13 @@ impl TraceSpan { if !self.inputs.is_null() { result.insert("inputs".to_string(), self.inputs.clone()); } - if let Some(ref val) = self.output { + if let Some(val) = self.output.as_ref() { result.insert("output".to_string(), val.clone()); } - if let Some(ref val) = self.error { + if let Some(val) = self.error.as_ref() { result.insert("error".to_string(), serde_json::Value::String(val.clone())); } - if let Some(ref val) = self.__usage { + if let Some(val) = self.__usage.as_ref() { let nested = val.to_value(ctx); if !nested.is_null() { result.insert("__usage".to_string(), nested); @@ -142,7 +171,7 @@ impl TraceSpan { if !self.attributes.is_null() { result.insert("attributes".to_string(), self.attributes.clone()); } - if let Some(ref items) = self.__frames { + if let Some(items) = self.__frames.as_ref() { result.insert( "__frames".to_string(), serde_json::to_value(items).unwrap_or(serde_json::Value::Null), @@ -185,6 +214,7 @@ impl serde::Serialize for TraceSpan { impl<'de> serde::Deserialize<'de> for TraceSpan { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/tracing/trace_time.rs b/runtime/rust/prompty/src/model/tracing/trace_time.rs index 1b3e19405..2ede624ae 100644 --- a/runtime/rust/prompty/src/model/tracing/trace_time.rs +++ b/runtime/rust/prompty/src/model/tracing/trace_time.rs @@ -31,12 +31,16 @@ impl TraceTime { /// Load TraceTime from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load TraceTime from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl TraceTime { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { start: value .get("start") @@ -63,6 +70,10 @@ impl TraceTime { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize TraceTime to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -115,6 +126,7 @@ impl serde::Serialize for TraceTime { impl<'de> serde::Deserialize<'de> for TraceTime { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/wire/anthropic_image_block.rs b/runtime/rust/prompty/src/model/wire/anthropic_image_block.rs index b13ff51f2..ec3b3542d 100644 --- a/runtime/rust/prompty/src/model/wire/anthropic_image_block.rs +++ b/runtime/rust/prompty/src/model/wire/anthropic_image_block.rs @@ -31,12 +31,16 @@ impl AnthropicImageBlock { /// Load AnthropicImageBlock from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load AnthropicImageBlock from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl AnthropicImageBlock { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { r#type: value .get("type") @@ -59,6 +66,20 @@ impl AnthropicImageBlock { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "source".to_string() + } else { + format!("{}.source", path) + }; + let child = value + .get("source") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + AnthropicImageSource::validate_input_at(child, &child_path)?; + Ok(()) + } + /// Serialize AnthropicImageBlock to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -103,6 +124,7 @@ impl serde::Serialize for AnthropicImageBlock { impl<'de> serde::Deserialize<'de> for AnthropicImageBlock { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/wire/anthropic_image_source.rs b/runtime/rust/prompty/src/model/wire/anthropic_image_source.rs index 271106dac..c948ed907 100644 --- a/runtime/rust/prompty/src/model/wire/anthropic_image_source.rs +++ b/runtime/rust/prompty/src/model/wire/anthropic_image_source.rs @@ -31,12 +31,16 @@ impl AnthropicImageSource { /// Load AnthropicImageSource from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load AnthropicImageSource from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl AnthropicImageSource { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { r#type: value .get("type") @@ -64,6 +71,10 @@ impl AnthropicImageSource { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize AnthropicImageSource to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -114,6 +125,7 @@ impl serde::Serialize for AnthropicImageSource { impl<'de> serde::Deserialize<'de> for AnthropicImageSource { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/wire/anthropic_messages_request.rs b/runtime/rust/prompty/src/model/wire/anthropic_messages_request.rs index aea41d071..5199db2d7 100644 --- a/runtime/rust/prompty/src/model/wire/anthropic_messages_request.rs +++ b/runtime/rust/prompty/src/model/wire/anthropic_messages_request.rs @@ -35,7 +35,7 @@ pub struct AnthropicMessagesRequest { /// Stop sequences to end generation pub stop_sequences: Option>, /// Tool definitions available to the model - pub tools: Vec, + pub tools: Option>, } impl AnthropicMessagesRequest { @@ -47,12 +47,16 @@ impl AnthropicMessagesRequest { /// Load AnthropicMessagesRequest from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load AnthropicMessagesRequest from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -61,6 +65,9 @@ impl AnthropicMessagesRequest { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { model: value .get("model") @@ -99,13 +106,42 @@ impl AnthropicMessagesRequest { .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect() }), - tools: value - .get("tools") - .map(|v| Self::load_tools(v, ctx)) - .unwrap_or_default(), + tools: value.get("tools").map(|v| Self::load_tools(v, ctx)), } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + if let Some(entries) = value + .get("messages") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "messages".to_string() + } else { + format!("{}.messages", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + AnthropicWireMessage::validate_input_at(entry, &entry_path)?; + } + } + if let Some(entries) = value + .get("tools") + .and_then(|candidate| candidate.as_array()) + { + let collection_path = if path.is_empty() { + "tools".to_string() + } else { + format!("{}.tools", path) + }; + for (index, entry) in entries.iter().enumerate() { + let entry_path = format!("{}[{}]", collection_path, index); + AnthropicToolDefinition::validate_input_at(entry, &entry_path)?; + } + } + Ok(()) + } + /// Serialize AnthropicMessagesRequest to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -130,39 +166,39 @@ impl AnthropicMessagesRequest { serde_json::Value::Number(serde_json::Number::from(self.max_tokens)), ); } - if let Some(ref val) = self.system { + if let Some(val) = self.system.as_ref() { result.insert("system".to_string(), serde_json::Value::String(val.clone())); } - if let Some(val) = self.temperature { + if let Some(val) = self.temperature.as_ref() { result.insert( "temperature".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); } - if let Some(val) = self.top_p { + if let Some(val) = self.top_p.as_ref() { result.insert( "top_p".to_string(), - serde_json::Number::from_f64(val as f64) + serde_json::Number::from_f64(*val as f64) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), ); } - if let Some(val) = self.top_k { + if let Some(val) = self.top_k.as_ref() { result.insert( "top_k".to_string(), - serde_json::Value::Number(serde_json::Number::from(val)), + serde_json::Value::Number(serde_json::Number::from(*val)), ); } - if let Some(ref items) = self.stop_sequences { + if let Some(items) = self.stop_sequences.as_ref() { result.insert( "stop_sequences".to_string(), serde_json::to_value(items).unwrap_or(serde_json::Value::Null), ); } - if !self.tools.is_empty() { - result.insert("tools".to_string(), Self::save_tools(&self.tools, ctx)); + if let Some(items) = self.tools.as_ref() { + result.insert("tools".to_string(), Self::save_tools(items, ctx)); } ctx.process_dict(serde_json::Value::Object(result)) } @@ -201,7 +237,7 @@ impl AnthropicMessagesRequest { } /// Load a collection of AnthropicToolDefinition from a JSON value. - /// Handles both array format `[{...}]` and dict format `{"name": {...}}`. + /// Handles both array format `[{...}]`. fn load_tools(data: &serde_json::Value, ctx: &LoadContext) -> Vec { match data { serde_json::Value::Array(arr) => arr @@ -209,54 +245,18 @@ impl AnthropicMessagesRequest { .map(|v| AnthropicToolDefinition::load_from_value(v, ctx)) .collect(), - serde_json::Value::Object(obj) => obj - .iter() - .filter_map(|(name, value)| { - if value.is_array() { - return None; - } - let mut v = if value.is_object() { - value.clone() - } else { - serde_json::json!({ "description": value }) - }; - if let serde_json::Value::Object(ref mut m) = v { - m.entry("name".to_string()) - .or_insert_with(|| serde_json::Value::String(name.clone())); - } - Some(AnthropicToolDefinition::load_from_value(&v, ctx)) - }) - .collect(), _ => Vec::new(), } } /// Save a collection of AnthropicToolDefinition to a JSON value. fn save_tools(items: &[AnthropicToolDefinition], ctx: &SaveContext) -> serde_json::Value { - if ctx.collection_format == "array" { - return serde_json::Value::Array( - items - .iter() - .map(|item| item.to_value(ctx)) - .collect::>(), - ); - } - // Object format: use name as key - let mut result = serde_json::Map::new(); - for item in items { - let mut item_data = match item.to_value(ctx) { - serde_json::Value::Object(m) => m, - other => { - let mut m = serde_json::Map::new(); - m.insert("value".to_string(), other); - m - } - }; - if let Some(serde_json::Value::String(name)) = item_data.remove("name") { - result.insert(name, serde_json::Value::Object(item_data)); - } - } - serde_json::Value::Object(result) + serde_json::Value::Array( + items + .iter() + .map(|item| item.to_value(ctx)) + .collect::>(), + ) } } @@ -272,6 +272,7 @@ impl serde::Serialize for AnthropicMessagesRequest { impl<'de> serde::Deserialize<'de> for AnthropicMessagesRequest { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/wire/anthropic_messages_response.rs b/runtime/rust/prompty/src/model/wire/anthropic_messages_response.rs index d2bdf2945..c70f73b6a 100644 --- a/runtime/rust/prompty/src/model/wire/anthropic_messages_response.rs +++ b/runtime/rust/prompty/src/model/wire/anthropic_messages_response.rs @@ -41,12 +41,16 @@ impl AnthropicMessagesResponse { /// Load AnthropicMessagesResponse from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load AnthropicMessagesResponse from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -55,6 +59,9 @@ impl AnthropicMessagesResponse { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { id: value .get("id") @@ -94,6 +101,20 @@ impl AnthropicMessagesResponse { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + let child_path = if path.is_empty() { + "usage".to_string() + } else { + format!("{}.usage", path) + }; + let child = value + .get("usage") + .filter(|candidate| !candidate.is_null()) + .ok_or_else(|| format!("{}: missing required field", child_path))?; + AnthropicUsage::validate_input_at(child, &child_path)?; + Ok(()) + } + /// Serialize AnthropicMessagesResponse to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -165,6 +186,7 @@ impl serde::Serialize for AnthropicMessagesResponse { impl<'de> serde::Deserialize<'de> for AnthropicMessagesResponse { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/wire/anthropic_text_block.rs b/runtime/rust/prompty/src/model/wire/anthropic_text_block.rs index f02fb7bb5..cbc63059c 100644 --- a/runtime/rust/prompty/src/model/wire/anthropic_text_block.rs +++ b/runtime/rust/prompty/src/model/wire/anthropic_text_block.rs @@ -29,12 +29,16 @@ impl AnthropicTextBlock { /// Load AnthropicTextBlock from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load AnthropicTextBlock from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -43,6 +47,9 @@ impl AnthropicTextBlock { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { r#type: value .get("type") @@ -57,6 +64,10 @@ impl AnthropicTextBlock { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize AnthropicTextBlock to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -101,6 +112,7 @@ impl serde::Serialize for AnthropicTextBlock { impl<'de> serde::Deserialize<'de> for AnthropicTextBlock { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/wire/anthropic_tool_definition.rs b/runtime/rust/prompty/src/model/wire/anthropic_tool_definition.rs index e1263fcce..d1ad36f57 100644 --- a/runtime/rust/prompty/src/model/wire/anthropic_tool_definition.rs +++ b/runtime/rust/prompty/src/model/wire/anthropic_tool_definition.rs @@ -31,12 +31,16 @@ impl AnthropicToolDefinition { /// Load AnthropicToolDefinition from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load AnthropicToolDefinition from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl AnthropicToolDefinition { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { name: value .get("name") @@ -62,6 +69,10 @@ impl AnthropicToolDefinition { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize AnthropicToolDefinition to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -74,7 +85,7 @@ impl AnthropicToolDefinition { serde_json::Value::String(self.name.clone()), ); } - if let Some(ref val) = self.description { + if let Some(val) = self.description.as_ref() { result.insert( "description".to_string(), serde_json::Value::String(val.clone()), @@ -114,6 +125,7 @@ impl serde::Serialize for AnthropicToolDefinition { impl<'de> serde::Deserialize<'de> for AnthropicToolDefinition { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/wire/anthropic_tool_result_block.rs b/runtime/rust/prompty/src/model/wire/anthropic_tool_result_block.rs index 3fce93091..dd2587019 100644 --- a/runtime/rust/prompty/src/model/wire/anthropic_tool_result_block.rs +++ b/runtime/rust/prompty/src/model/wire/anthropic_tool_result_block.rs @@ -31,12 +31,16 @@ impl AnthropicToolResultBlock { /// Load AnthropicToolResultBlock from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load AnthropicToolResultBlock from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -45,6 +49,9 @@ impl AnthropicToolResultBlock { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { r#type: value .get("type") @@ -64,6 +71,10 @@ impl AnthropicToolResultBlock { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize AnthropicToolResultBlock to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -114,6 +125,7 @@ impl serde::Serialize for AnthropicToolResultBlock { impl<'de> serde::Deserialize<'de> for AnthropicToolResultBlock { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/wire/anthropic_tool_use_block.rs b/runtime/rust/prompty/src/model/wire/anthropic_tool_use_block.rs index 62472a0ff..5d0d10743 100644 --- a/runtime/rust/prompty/src/model/wire/anthropic_tool_use_block.rs +++ b/runtime/rust/prompty/src/model/wire/anthropic_tool_use_block.rs @@ -33,12 +33,16 @@ impl AnthropicToolUseBlock { /// Load AnthropicToolUseBlock from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load AnthropicToolUseBlock from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -47,6 +51,9 @@ impl AnthropicToolUseBlock { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { r#type: value .get("type") @@ -70,6 +77,10 @@ impl AnthropicToolUseBlock { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize AnthropicToolUseBlock to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -125,6 +136,7 @@ impl serde::Serialize for AnthropicToolUseBlock { impl<'de> serde::Deserialize<'de> for AnthropicToolUseBlock { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/wire/anthropic_usage.rs b/runtime/rust/prompty/src/model/wire/anthropic_usage.rs index 531b6705d..6e470273e 100644 --- a/runtime/rust/prompty/src/model/wire/anthropic_usage.rs +++ b/runtime/rust/prompty/src/model/wire/anthropic_usage.rs @@ -29,12 +29,16 @@ impl AnthropicUsage { /// Load AnthropicUsage from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load AnthropicUsage from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -43,6 +47,9 @@ impl AnthropicUsage { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { input_tokens: value .get("input_tokens") @@ -55,6 +62,10 @@ impl AnthropicUsage { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize AnthropicUsage to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -99,6 +110,7 @@ impl serde::Serialize for AnthropicUsage { impl<'de> serde::Deserialize<'de> for AnthropicUsage { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model/wire/anthropic_wire_message.rs b/runtime/rust/prompty/src/model/wire/anthropic_wire_message.rs index 3d892a839..900c438ad 100644 --- a/runtime/rust/prompty/src/model/wire/anthropic_wire_message.rs +++ b/runtime/rust/prompty/src/model/wire/anthropic_wire_message.rs @@ -29,12 +29,16 @@ impl AnthropicWireMessage { /// Load AnthropicWireMessage from a JSON string. pub fn from_json(json: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_json::from_str(json)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } /// Load AnthropicWireMessage from a YAML string. pub fn from_yaml(yaml: &str, ctx: &LoadContext) -> Result { let value: serde_json::Value = serde_yaml::from_str(yaml)?; + Self::validate_input_at(&value, "") + .map_err(|message| ::custom(message))?; Ok(Self::load_from_value(&value, ctx)) } @@ -43,6 +47,9 @@ impl AnthropicWireMessage { /// Calls `ctx.process_input` before field extraction. pub fn load_from_value(value: &serde_json::Value, ctx: &LoadContext) -> Self { let value = ctx.process_input(value.clone()); + if let Err(message) = Self::validate_input_at(&value, "") { + panic!("{}", message); + } Self { role: value .get("role") @@ -57,6 +64,10 @@ impl AnthropicWireMessage { } } + pub(crate) fn validate_input_at(value: &serde_json::Value, path: &str) -> Result<(), String> { + Ok(()) + } + /// Serialize AnthropicWireMessage to a `serde_json::Value`. /// /// Calls `ctx.process_dict` after serialization. @@ -101,6 +112,7 @@ impl serde::Serialize for AnthropicWireMessage { impl<'de> serde::Deserialize<'de> for AnthropicWireMessage { fn deserialize>(deserializer: D) -> Result { let value = ::deserialize(deserializer)?; + Self::validate_input_at(&value, "").map_err(serde::de::Error::custom)?; Ok(Self::load_from_value(&value, &LoadContext::default())) } } diff --git a/runtime/rust/prompty/src/model_ext.rs b/runtime/rust/prompty/src/model_ext.rs index dfdb85d96..7391f94f1 100644 --- a/runtime/rust/prompty/src/model_ext.rs +++ b/runtime/rust/prompty/src/model_ext.rs @@ -14,22 +14,14 @@ use crate::model::{ // --------------------------------------------------------------------------- impl Prompty { - /// Returns a reference to the input properties, or `None` if empty. + /// Returns a reference to the input properties, or `None` if absent or empty. pub fn as_inputs(&self) -> Option<&Vec> { - if self.inputs.is_empty() { - None - } else { - Some(&self.inputs) - } + self.inputs.as_ref().filter(|items| !items.is_empty()) } - /// Returns a reference to the output properties, or `None` if empty. + /// Returns a reference to the output properties, or `None` if absent or empty. pub fn as_outputs(&self) -> Option<&Vec> { - if self.outputs.is_empty() { - None - } else { - Some(&self.outputs) - } + self.outputs.as_ref().filter(|items| !items.is_empty()) } /// Returns a reference to the tools list, or `None` if empty. diff --git a/runtime/rust/prompty/src/pipeline.rs b/runtime/rust/prompty/src/pipeline.rs index d2ca781ad..347180bc0 100644 --- a/runtime/rust/prompty/src/pipeline.rs +++ b/runtime/rust/prompty/src/pipeline.rs @@ -94,8 +94,8 @@ fn resolve_parser_kind(agent: &Prompty) -> String { fn resolve_provider(agent: &Prompty) -> String { agent .model - .provider - .as_deref() + .as_ref() + .and_then(|m| m.provider.as_deref()) .filter(|p| !p.is_empty()) .unwrap_or(DEFAULT_PROVIDER) .to_string() @@ -105,8 +105,8 @@ fn resolve_provider(agent: &Prompty) -> String { fn is_streaming(agent: &Prompty) -> bool { agent .model - .options .as_ref() + .and_then(|m| m.options.as_ref()) .and_then(|opts| { opts.additional_properties .get("stream") @@ -180,9 +180,18 @@ fn serialize_agent(agent: &Prompty) -> Value { "description": agent.description, "metadata": metadata, "model": { - "id": agent.model.id, - "apiType": agent.model.api_type.as_ref().map(|t| t.as_str()).unwrap_or("chat"), - "provider": agent.model.provider.as_deref().unwrap_or(""), + "id": agent.model.as_ref().map(|m| m.id.clone()), + "apiType": agent + .model + .as_ref() + .and_then(|m| m.api_type.as_ref()) + .map(|t| t.as_str()) + .unwrap_or("chat"), + "provider": agent + .model + .as_ref() + .and_then(|m| m.provider.as_deref()) + .unwrap_or(""), }, "inputs": inputs, "outputs": outputs, diff --git a/runtime/rust/prompty/src/pipeline/live_turn.rs b/runtime/rust/prompty/src/pipeline/live_turn.rs index f2ed53174..c7ba268da 100644 --- a/runtime/rust/prompty/src/pipeline/live_turn.rs +++ b/runtime/rust/prompty/src/pipeline/live_turn.rs @@ -1251,7 +1251,11 @@ pub(super) async fn turn_with_engine_request( events: events.clone(), agent_name: Some(agent.name.clone()), provider: provider.clone(), - model_id: (!agent.model.id.is_empty()).then(|| agent.model.id.clone()), + model_id: agent + .model + .as_ref() + .map(|m| m.id.clone()) + .filter(|id| !id.is_empty()), configured_max_iterations: max_iterations, agent_mode, persistence, @@ -2066,7 +2070,19 @@ mod tests { skip_output_guardrail: Arc::new(AtomicBool::new(false)), failures: Arc::new(LiveFailureState::default()), }; - let request = ModelInvocationRequest::load_from_value(&json!({}), &LoadContext::default()); + let request = ModelInvocationRequest::load_from_value( + &json!({ + "context": { + "id": "context:inv_streamed_tool_round", + "sessionId": "sess_streamed_tool_round", + "turnId": "turn_streamed_tool_round", + "invocationId": "inv_streamed_tool_round", + "iteration": 0, + "contextState": {} + } + }), + &LoadContext::default(), + ); let response = port .invoke( diff --git a/runtime/rust/prompty/src/tool_dispatch.rs b/runtime/rust/prompty/src/tool_dispatch.rs index 012147a8d..588c640a6 100644 --- a/runtime/rust/prompty/src/tool_dispatch.rs +++ b/runtime/rust/prompty/src/tool_dispatch.rs @@ -1050,7 +1050,8 @@ mod tests { let agent = agent_with_tools(serde_json::json!([{ "name": "my_mcp_tool", "kind": "mcp", - "serverName": "test-server" + "serverName": "test-server", + "connection": { "kind": "reference", "name": "test-mcp" } }])); let tc = make_tool_call("my_mcp_tool", "{}"); @@ -1070,7 +1071,8 @@ mod tests { // Agent has a tool with unknown kind — should fall through to "*" wildcard let agent = agent_with_tools(serde_json::json!([{ "name": "my_exotic_tool", - "kind": "exotic_provider" + "kind": "exotic_provider", + "connection": { "kind": "reference", "name": "exotic" } }])); let tc = make_tool_call("my_exotic_tool", "{}"); diff --git a/runtime/rust/prompty/tests/connection_roundtrip_vectors.rs b/runtime/rust/prompty/tests/connection_roundtrip_vectors.rs new file mode 100644 index 000000000..46c56441a --- /dev/null +++ b/runtime/rust/prompty/tests/connection_roundtrip_vectors.rs @@ -0,0 +1,81 @@ +//! Cross-runtime Connection roundtrip tests backed by the shared model vectors. + +use std::path::PathBuf; + +use prompty::model::Connection; +use prompty::model::context::{LoadContext, SaveContext}; +use serde_json::Value; + +fn vectors_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("runtime/rust/prompty must have a rust parent") + .parent() + .expect("runtime/rust must have a runtime parent") + .parent() + .expect("runtime must have a repository parent") + .join("spec") + .join("vectors") + .join("model") + .join("connection_roundtrip_vectors.json") +} + +#[test] +fn known_reference_connection_roundtrip_unchanged() { + assert_connection_roundtrip_vector("known_reference_connection_roundtrip_unchanged"); +} + +#[test] +fn unknown_connection_kind_preserves_payload() { + assert_connection_roundtrip_vector("unknown_connection_kind_preserves_payload"); +} + +#[test] +fn unknown_connection_case_collision_preserves_payload() { + assert_connection_roundtrip_vector("unknown_connection_case_collision_preserves_payload"); +} + +fn assert_connection_roundtrip_vector(vector_name: &str) { + let raw = std::fs::read_to_string(vectors_path()) + .expect("failed to read Connection roundtrip vectors"); + let document: Value = + serde_json::from_str(&raw).expect("failed to parse Connection roundtrip vectors"); + let vector = document["vectors"] + .as_array() + .expect("Connection roundtrip vectors must contain a vectors array") + .iter() + .find(|candidate| candidate["name"] == vector_name) + .unwrap_or_else(|| panic!("missing Connection roundtrip vector {vector_name}")); + assert_eq!( + vector["operation"], "load-save-reload", + "[{vector_name}] unsupported vector operation" + ); + + let input = &vector["input"]; + let expected = &vector["expected"]; + let kind = input["kind"] + .as_str() + .expect("Connection kind must be a string"); + let load_context = LoadContext::default(); + let save_context = SaveContext::default(); + + let loaded = Connection::load_from_value(input, &load_context); + assert_eq!( + loaded.kind_str(), + kind, + "[{vector_name}] load changed the discriminator" + ); + + let saved = loaded.to_value(&save_context); + assert_eq!( + saved, *expected, + "[{vector_name}] save changed the Connection payload" + ); + + let reloaded = Connection::load_from_value(&saved, &load_context); + let resaved = reloaded.to_value(&save_context); + assert_eq!( + resaved, *expected, + "[{vector_name}] reload changed the Connection payload" + ); +} diff --git a/runtime/rust/prompty/tests/content_part_discriminator_vectors.rs b/runtime/rust/prompty/tests/content_part_discriminator_vectors.rs new file mode 100644 index 000000000..d8518c78d --- /dev/null +++ b/runtime/rust/prompty/tests/content_part_discriminator_vectors.rs @@ -0,0 +1,92 @@ +//! Closed ContentPart discriminator tests backed by the shared model vectors. + +use std::path::PathBuf; + +use prompty::model::ContentPart; +use prompty::model::context::{LoadContext, SaveContext}; +use serde_json::Value; + +fn vectors_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("runtime/rust/prompty must have a rust parent") + .parent() + .expect("runtime/rust must have a runtime parent") + .parent() + .expect("runtime must have a repository parent") + .join("spec") + .join("vectors") + .join("model") + .join("content_part_discriminator_vectors.json") +} + +#[test] +fn known_text_content_part_loads() { + assert_content_part_discriminator_vector("known_text_content_part_loads"); +} + +#[test] +fn unknown_content_part_kind_is_rejected() { + assert_content_part_discriminator_vector("unknown_content_part_kind_is_rejected"); +} + +#[test] +fn content_part_case_collision_is_rejected() { + assert_content_part_discriminator_vector("content_part_case_collision_is_rejected"); +} + +fn assert_content_part_discriminator_vector(vector_name: &str) { + let raw = std::fs::read_to_string(vectors_path()) + .expect("failed to read ContentPart discriminator vectors"); + let document: Value = + serde_json::from_str(&raw).expect("failed to parse ContentPart discriminator vectors"); + let vector = document["vectors"] + .as_array() + .expect("ContentPart discriminator vectors must contain a vectors array") + .iter() + .find(|candidate| candidate["name"] == vector_name) + .unwrap_or_else(|| panic!("missing ContentPart discriminator vector {vector_name}")); + let context = LoadContext::default(); + + let input = &vector["input"]; + let json = serde_json::to_string(input).expect("vector input must be JSON-compatible"); + let result = ContentPart::from_json(&json, &context); + + match vector["operation"].as_str() { + Some("load") => { + let content_part = result.unwrap_or_else(|error| { + panic!("[{vector_name}] known ContentPart failed to load: {error}") + }); + assert_eq!( + content_part.to_value(&SaveContext::default()), + vector["expected"], + "[{vector_name}] known ContentPart payload changed during load/save" + ); + } + Some("load-error") => { + let error = match result { + Ok(_) => panic!( + "[{vector_name}] closed ContentPart accepted unknown discriminator {:?}", + input["kind"] + ), + Err(error) => error, + }; + let diagnostic = error.to_string(); + let discriminator = vector["expected"]["discriminator"] + .as_str() + .expect("error vector must declare the discriminator field"); + let value = vector["expected"]["value"] + .as_str() + .expect("error vector must declare the exact discriminator value"); + assert!( + diagnostic.contains(discriminator), + "[{vector_name}] error diagnostic did not identify discriminator {discriminator:?}: {diagnostic}" + ); + assert!( + diagnostic.contains(value), + "[{vector_name}] error diagnostic did not preserve discriminator value {value:?}: {diagnostic}" + ); + } + operation => panic!("[{vector_name}] unsupported vector operation: {operation:?}"), + } +} diff --git a/runtime/rust/prompty/tests/live_turn_execution.rs b/runtime/rust/prompty/tests/live_turn_execution.rs index e46761305..3bd440c27 100644 --- a/runtime/rust/prompty/tests/live_turn_execution.rs +++ b/runtime/rust/prompty/tests/live_turn_execution.rs @@ -307,7 +307,11 @@ async fn public_streaming_turn_cancels_after_open_and_persists_terminal_event() }; let durability = Arc::new(RecordingDurability::default()); let mut streaming_agent = agent(provider); - streaming_agent.model.options = Some(prompty::model::ModelOptions::load_from_value( + streaming_agent + .model + .as_mut() + .expect("fixture declares a model") + .options = Some(prompty::model::ModelOptions::load_from_value( &json!({"additionalProperties": {"stream": true}}), &LoadContext::default(), )); diff --git a/runtime/rust/prompty/tests/load_vectors.rs b/runtime/rust/prompty/tests/load_vectors.rs index 7469499d7..564ff54a4 100644 --- a/runtime/rust/prompty/tests/load_vectors.rs +++ b/runtime/rust/prompty/tests/load_vectors.rs @@ -192,14 +192,19 @@ fn validate_agent_fields(agent: &prompty::model::Prompty, expected: &Value, vec_ // model if let Some(model) = expected.get("model") { if model.is_null() { - // expected null model → id should be empty (default) + // The vector asks for a null model; assert exactly that rather than + // inferring it from an empty id. assert!( - agent.model.id.is_empty(), - "[{vec_name}] expected null/empty model, got id='{}'", - agent.model.id + agent.model.is_none(), + "[{vec_name}] expected null model, got id={:?}", + agent.model.as_ref().map(|m| m.id.clone()) ); } else { - validate_model(&agent.model, model, vec_name); + let actual = agent + .model + .as_ref() + .unwrap_or_else(|| panic!("[{vec_name}] expected a model, got none")); + validate_model(actual, model, vec_name); } } @@ -547,28 +552,6 @@ fn run_error_vector(vec_name: &str, input: &Value, expected: &Value, env: &Value match result { Ok(_) => { - // Special case: template_string_invalid — the Rust runtime's generated - // Template::load_from_value accepts strings (they produce an empty Template). - // This is a known behavioral difference from the Python runtime. - if vec_name == "template_string_invalid" { - // Verify the template is effectively empty/broken (empty kind strings) - let agent = attempt_load(input, env).unwrap(); - let tmpl = agent.template.as_ref(); - if let Some(t) = tmpl { - // Template was created from a bare string — format/parser kinds - // will be empty because Template::load_from_value only reads - // "format"/"parser" sub-keys which don't exist on a string. - assert!( - t.format.kind.is_empty() && t.parser.kind.is_empty(), - "[{vec_name}] template_string_invalid: expected empty format/parser kinds, \ - got format.kind='{}', parser.kind='{}'", - t.format.kind, - t.parser.kind - ); - } - // Pass — runtime doesn't error but produces an unusable template - return; - } panic!("[{vec_name}] expected error containing '{expected_err}', but load succeeded"); } Err(err) => { diff --git a/runtime/rust/prompty/tests/loader_test.rs b/runtime/rust/prompty/tests/loader_test.rs index 07ffeb161..9f99abe5c 100644 --- a/runtime/rust/prompty/tests/loader_test.rs +++ b/runtime/rust/prompty/tests/loader_test.rs @@ -20,6 +20,16 @@ fn fixtures_dir() -> PathBuf { .join("fixtures") } +/// Path to the canonical load vectors. +fn load_vectors_path() -> PathBuf { + fixtures_dir() + .parent() + .expect("spec/fixtures must have a spec parent") + .join("vectors") + .join("load") + .join("load_vectors.json") +} + /// Load a fixture `.prompty` file with optional env vars set. fn load_fixture( name: &str, @@ -81,21 +91,21 @@ fn test_basic_load() { agent.description.as_deref(), Some("A basic prompt for testing") ); - assert_eq!(agent.model.id, "gpt-4"); - assert_eq!(agent.model.provider.as_deref(), Some("openai")); + assert_eq!(model_of(&agent).id, "gpt-4"); + assert_eq!(model_of(&agent).provider.as_deref(), Some("openai")); assert_eq!( - agent.model.api_type.as_ref().map(|t| t.as_str()), + model_of(&agent).api_type.as_ref().map(|t| t.as_str()), Some("chat") ); // Connection - let conn = agent.model.connection.as_object().unwrap(); + let conn = model_of(&agent).connection.as_object().unwrap(); assert_eq!(conn["kind"], "key"); assert_eq!(conn["endpoint"], "https://test.openai.com"); assert_eq!(conn["apiKey"], "sk-test123"); // Options - let opts = agent.model.options.as_ref().unwrap(); + let opts = model_of(&agent).options.as_ref().unwrap(); assert!((opts.temperature.unwrap() - 0.7_f32).abs() < f32::EPSILON); assert_eq!(opts.max_output_tokens.unwrap(), 1000); @@ -125,7 +135,7 @@ fn test_minimal_load() { let agent = load_fixture("minimal.prompty", &[]).unwrap(); assert_eq!(agent.name, "minimal"); - assert_eq!(agent.model.id, "gpt-4"); + assert_eq!(model_of(&agent).id, "gpt-4"); assert_eq!(agent.instructions.as_deref(), Some("system:\nHello world.")); assert!(agent.as_inputs().is_none()); assert!(agent.as_outputs().is_none()); @@ -140,7 +150,7 @@ fn test_model_shorthand() { "model": "gpt-4o" }); let agent = load_from_frontmatter(&fm, &[]).unwrap(); - assert_eq!(agent.model.id, "gpt-4o"); + assert_eq!(model_of(&agent).id, "gpt-4o"); } #[test] @@ -157,7 +167,7 @@ fn test_env_resolution() { } }); let agent = load_from_frontmatter(&fm, &[("MY_VAR", "hello")]).unwrap(); - let conn = agent.model.connection.as_object().unwrap(); + let conn = model_of(&agent).connection.as_object().unwrap(); assert_eq!(conn["endpoint"], "hello"); } @@ -175,7 +185,7 @@ fn test_env_default() { } }); let agent = load_from_frontmatter(&fm, &[]).unwrap(); - let conn = agent.model.connection.as_object().unwrap(); + let conn = model_of(&agent).connection.as_object().unwrap(); assert_eq!(conn["endpoint"], "fallback_value"); } @@ -252,7 +262,7 @@ fn test_tools_function_load() { assert_eq!(agent.name, "function-tools"); assert_eq!( - agent.model.api_type.as_ref().map(|t| t.as_str()), + model_of(&agent).api_type.as_ref().map(|t| t.as_str()), Some("chat") ); @@ -260,6 +270,32 @@ fn test_tools_function_load() { assert_eq!(tools.len(), 1); assert_eq!(tools[0].name, "get_weather"); assert_eq!(tools[0].kind_str(), "function"); + + let raw = std::fs::read_to_string(load_vectors_path()).unwrap(); + let vectors: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let vector = vectors + .as_array() + .unwrap() + .iter() + .find(|vector| vector["name"] == "tools_function_load") + .unwrap(); + let expected_bindings = vector["expected"]["tools"][0]["bindings"] + .as_object() + .unwrap(); + + assert_eq!(tools[0].bindings.len(), expected_bindings.len()); + for (name, expected) in expected_bindings { + let actual = tools[0] + .bindings + .iter() + .find(|binding| binding.name == *name) + .unwrap_or_else(|| panic!("missing binding {name:?}")); + assert_eq!( + actual.input, + expected["input"].as_str().unwrap(), + "binding {name:?} input mismatch" + ); + } } #[test] @@ -275,9 +311,9 @@ fn test_embedding_load() { .unwrap(); assert_eq!(agent.name, "embedding"); - assert_eq!(agent.model.id, "text-embedding-3-small"); + assert_eq!(model_of(&agent).id, "text-embedding-3-small"); assert_eq!( - agent.model.api_type.as_ref().map(|t| t.as_str()), + model_of(&agent).api_type.as_ref().map(|t| t.as_str()), Some("embedding") ); } @@ -308,7 +344,7 @@ fn test_connection_types_load() { } }); let agent = load_from_frontmatter(&fm, &[]).unwrap(); - let conn = agent.model.connection.as_object().unwrap(); + let conn = model_of(&agent).connection.as_object().unwrap(); assert_eq!(conn["kind"], "anonymous"); assert_eq!(conn["endpoint"], "https://localhost:8080"); } @@ -429,3 +465,13 @@ fn test_threaded_load() { let thread_input = inputs.iter().find(|i| i.kind_str() == "thread"); assert!(thread_input.is_some(), "Expected a thread-kind input"); } + +/// Tests in this file all load fixtures that declare a model. `Prompty::model` +/// is optional (a name-only prompt is valid per load_vectors.json), so unwrap it +/// once here instead of scattering `.as_ref().unwrap()` through every assertion. +fn model_of(agent: &prompty::model::prompty::Prompty) -> &prompty::model::model::Model { + agent + .model + .as_ref() + .expect("fixture was expected to declare a model") +} diff --git a/runtime/rust/prompty/tests/model/agent/prompty_test.rs b/runtime/rust/prompty/tests/model/agent/prompty_test.rs index a0a8ab299..d7c0c752f 100644 --- a/runtime/rust/prompty/tests/model/agent/prompty_test.rs +++ b/runtime/rust/prompty/tests/model/agent/prompty_test.rs @@ -160,26 +160,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" "####; let ctx = LoadContext::default(); @@ -632,26 +613,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" "####; let ctx = LoadContext::default(); @@ -1023,26 +985,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" "####; let ctx = LoadContext::default(); @@ -1505,26 +1448,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" "####; let ctx = LoadContext::default(); @@ -1981,26 +1905,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" "####; let ctx = LoadContext::default(); @@ -2469,26 +2374,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" "####; let ctx = LoadContext::default(); @@ -2950,26 +2836,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" "####; let ctx = LoadContext::default(); @@ -3448,26 +3315,7 @@ tools: template: format: mustache parser: prompty -instructions: "system: - - You are an AI assistant who helps people find information. - - As the assistant, you answer questions briefly, succinctly, - - and in a personable manner using markdown and even add some\ - - personal flair with appropriate emojis. - - - # Customer - - You are helping {{firstName}} {{lastName}} to find answers to\ - - their questions. Use their name to address them in your responses. - - user: - - {{question}}" +instructions: "system:\nYou are an AI assistant who helps people find information.\nAs the assistant, you answer questions briefly, succinctly,\nand in a personable manner using markdown and even add some \npersonal flair with appropriate emojis.\n\n# Customer\nYou are helping {{firstName}} {{lastName}} to find answers to \ntheir questions. Use their name to address them in your responses.\nuser:\n{{question}}" "####; let ctx = LoadContext::default(); diff --git a/runtime/rust/prompty/tests/model/core/property_test.rs b/runtime/rust/prompty/tests/model/core/property_test.rs index 4be77613b..1131ab8d8 100644 --- a/runtime/rust/prompty/tests/model/core/property_test.rs +++ b/runtime/rust/prompty/tests/model/core/property_test.rs @@ -92,7 +92,12 @@ fn test_property_from_input() { let value = serde_json::json!(false); let ctx = LoadContext::default(); let instance = Property::load_from_value(&value, &ctx); - let _ = instance; // abstract type, load succeeded + let saved = instance.to_value(&SaveContext::default()); + let reloaded = Property::load_from_value(&saved, &ctx); + assert_eq!( + reloaded, instance, + "scalar-coerced abstract models must survive save/reload" + ); } #[test] @@ -100,7 +105,12 @@ fn test_property_from_input_2() { let value = serde_json::json!(3.14); let ctx = LoadContext::default(); let instance = Property::load_from_value(&value, &ctx); - let _ = instance; // abstract type, load succeeded + let saved = instance.to_value(&SaveContext::default()); + let reloaded = Property::load_from_value(&saved, &ctx); + assert_eq!( + reloaded, instance, + "scalar-coerced abstract models must survive save/reload" + ); } #[test] @@ -108,7 +118,12 @@ fn test_property_from_input_3() { let value = serde_json::json!(4); let ctx = LoadContext::default(); let instance = Property::load_from_value(&value, &ctx); - let _ = instance; // abstract type, load succeeded + let saved = instance.to_value(&SaveContext::default()); + let reloaded = Property::load_from_value(&saved, &ctx); + assert_eq!( + reloaded, instance, + "scalar-coerced abstract models must survive save/reload" + ); } #[test] @@ -116,5 +131,10 @@ fn test_property_from_input_4() { let value = serde_json::json!("example"); let ctx = LoadContext::default(); let instance = Property::load_from_value(&value, &ctx); - let _ = instance; // abstract type, load succeeded + let saved = instance.to_value(&SaveContext::default()); + let reloaded = Property::load_from_value(&saved, &ctx); + assert_eq!( + reloaded, instance, + "scalar-coerced abstract models must survive save/reload" + ); } diff --git a/runtime/rust/prompty/tests/model/events/session_trace_test.rs b/runtime/rust/prompty/tests/model/events/session_trace_test.rs index 50863d339..9b62ff8e3 100644 --- a/runtime/rust/prompty/tests/model/events/session_trace_test.rs +++ b/runtime/rust/prompty/tests/model/events/session_trace_test.rs @@ -19,7 +19,18 @@ fn test_session_trace_load_json() { "version": "1", "runtime": "typescript", "promptyVersion": "2.0.0", - "sessionId": "sess_abc123" + "sessionId": "sess_abc123", + "events": [ + { + "id": "evt_abc123", + "type": "session_start", + "timestamp": "2026-06-09T20:00:00Z", + "sessionId": "sess_abc123", + "turnId": "turn_001", + "parentId": "evt_parent", + "spanId": "span_hook_001" + } + ] } "####; let ctx = LoadContext::default(); @@ -52,6 +63,14 @@ version: "1" runtime: typescript promptyVersion: 2.0.0 sessionId: sess_abc123 +events: + - id: evt_abc123 + type: session_start + timestamp: "2026-06-09T20:00:00Z" + sessionId: sess_abc123 + turnId: turn_001 + parentId: evt_parent + spanId: span_hook_001 "####; let ctx = LoadContext::default(); @@ -81,7 +100,18 @@ fn test_session_trace_roundtrip() { "version": "1", "runtime": "typescript", "promptyVersion": "2.0.0", - "sessionId": "sess_abc123" + "sessionId": "sess_abc123", + "events": [ + { + "id": "evt_abc123", + "type": "session_start", + "timestamp": "2026-06-09T20:00:00Z", + "sessionId": "sess_abc123", + "turnId": "turn_001", + "parentId": "evt_parent", + "spanId": "span_hook_001" + } + ] } "####; let load_ctx = LoadContext::default(); @@ -104,7 +134,18 @@ fn test_session_trace_serde_roundtrip() { "version": "1", "runtime": "typescript", "promptyVersion": "2.0.0", - "sessionId": "sess_abc123" + "sessionId": "sess_abc123", + "events": [ + { + "id": "evt_abc123", + "type": "session_start", + "timestamp": "2026-06-09T20:00:00Z", + "sessionId": "sess_abc123", + "turnId": "turn_001", + "parentId": "evt_parent", + "spanId": "span_hook_001" + } + ] } "####; let instance: SessionTrace = diff --git a/runtime/rust/prompty/tests/model/events/turn_trace_test.rs b/runtime/rust/prompty/tests/model/events/turn_trace_test.rs index 6219818bf..931662e41 100644 --- a/runtime/rust/prompty/tests/model/events/turn_trace_test.rs +++ b/runtime/rust/prompty/tests/model/events/turn_trace_test.rs @@ -18,7 +18,18 @@ fn test_turn_trace_load_json() { { "version": "1", "runtime": "typescript", - "promptyVersion": "2.0.0" + "promptyVersion": "2.0.0", + "events": [ + { + "id": "evt_abc123", + "type": "turn_start", + "timestamp": "2026-06-09T20:00:00Z", + "turnId": "turn_001", + "iteration": 0, + "parentId": "evt_parent", + "spanId": "span_tool_001" + } + ] } "####; let ctx = LoadContext::default(); @@ -45,6 +56,14 @@ fn test_turn_trace_load_yaml() { version: "1" runtime: typescript promptyVersion: 2.0.0 +events: + - id: evt_abc123 + type: turn_start + timestamp: "2026-06-09T20:00:00Z" + turnId: turn_001 + iteration: 0 + parentId: evt_parent + spanId: span_tool_001 "####; let ctx = LoadContext::default(); @@ -69,7 +88,18 @@ fn test_turn_trace_roundtrip() { { "version": "1", "runtime": "typescript", - "promptyVersion": "2.0.0" + "promptyVersion": "2.0.0", + "events": [ + { + "id": "evt_abc123", + "type": "turn_start", + "timestamp": "2026-06-09T20:00:00Z", + "turnId": "turn_001", + "iteration": 0, + "parentId": "evt_parent", + "spanId": "span_tool_001" + } + ] } "####; let load_ctx = LoadContext::default(); @@ -91,7 +121,18 @@ fn test_turn_trace_serde_roundtrip() { { "version": "1", "runtime": "typescript", - "promptyVersion": "2.0.0" + "promptyVersion": "2.0.0", + "events": [ + { + "id": "evt_abc123", + "type": "turn_start", + "timestamp": "2026-06-09T20:00:00Z", + "turnId": "turn_001", + "iteration": 0, + "parentId": "evt_parent", + "spanId": "span_tool_001" + } + ] } "####; let instance: TurnTrace = diff --git a/runtime/rust/prompty/tests/model/pipeline/delegated_state_reference_test.rs b/runtime/rust/prompty/tests/model/pipeline/delegated_state_reference_test.rs index c05a37085..7ab58352c 100644 --- a/runtime/rust/prompty/tests/model/pipeline/delegated_state_reference_test.rs +++ b/runtime/rust/prompty/tests/model/pipeline/delegated_state_reference_test.rs @@ -100,10 +100,6 @@ fn test_delegated_state_reference_serde_roundtrip() { DelegatedStateReference::load_from_value(&canonical, &LoadContext::default()), "serde deserialize must equal canonical load_from_value" ); - assert_eq!( - value, canonical, - "serde must serialize to byte-identical canonical wire (empty-omission preserved; no plain-derive divergence)" - ); let reparsed: DelegatedStateReference = serde_json::from_value(value).expect("serde should re-deserialize"); assert_eq!(instance, reparsed, "serde round-trip must be stable"); diff --git a/runtime/rust/prompty/tests/model/pipeline/engine_checkpoint_test.rs b/runtime/rust/prompty/tests/model/pipeline/engine_checkpoint_test.rs index 21ab7cace..2ccb3c33d 100644 --- a/runtime/rust/prompty/tests/model/pipeline/engine_checkpoint_test.rs +++ b/runtime/rust/prompty/tests/model/pipeline/engine_checkpoint_test.rs @@ -19,7 +19,8 @@ fn test_engine_checkpoint_load_json() { "id": "ckpt_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "runId": "run_abc123" + "runId": "run_abc123", + "contextState": {} } "####; let ctx = LoadContext::default(); @@ -43,6 +44,7 @@ id: ckpt_abc123 sessionId: sess_abc123 turnId: turn_abc123 runId: run_abc123 +contextState: {} "####; let ctx = LoadContext::default(); @@ -66,7 +68,8 @@ fn test_engine_checkpoint_roundtrip() { "id": "ckpt_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "runId": "run_abc123" + "runId": "run_abc123", + "contextState": {} } "####; let load_ctx = LoadContext::default(); @@ -89,7 +92,8 @@ fn test_engine_checkpoint_serde_roundtrip() { "id": "ckpt_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "runId": "run_abc123" + "runId": "run_abc123", + "contextState": {} } "####; let instance: EngineCheckpoint = diff --git a/runtime/rust/prompty/tests/model/pipeline/model_invocation_context_snapshot_test.rs b/runtime/rust/prompty/tests/model/pipeline/model_invocation_context_snapshot_test.rs index 50f283a72..092e50a47 100644 --- a/runtime/rust/prompty/tests/model/pipeline/model_invocation_context_snapshot_test.rs +++ b/runtime/rust/prompty/tests/model/pipeline/model_invocation_context_snapshot_test.rs @@ -19,7 +19,8 @@ fn test_model_invocation_context_snapshot_load_json() { "id": "context:inv_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "invocationId": "inv_abc123" + "invocationId": "inv_abc123", + "contextState": {} } "####; let ctx = LoadContext::default(); @@ -43,6 +44,7 @@ id: "context:inv_abc123" sessionId: sess_abc123 turnId: turn_abc123 invocationId: inv_abc123 +contextState: {} "####; let ctx = LoadContext::default(); @@ -66,7 +68,8 @@ fn test_model_invocation_context_snapshot_roundtrip() { "id": "context:inv_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "invocationId": "inv_abc123" + "invocationId": "inv_abc123", + "contextState": {} } "####; let load_ctx = LoadContext::default(); @@ -89,7 +92,8 @@ fn test_model_invocation_context_snapshot_serde_roundtrip() { "id": "context:inv_abc123", "sessionId": "sess_abc123", "turnId": "turn_abc123", - "invocationId": "inv_abc123" + "invocationId": "inv_abc123", + "contextState": {} } "####; let instance: ModelInvocationContextSnapshot = diff --git a/runtime/rust/prompty/tests/model/pipeline/model_reconciliation_state_test.rs b/runtime/rust/prompty/tests/model/pipeline/model_reconciliation_state_test.rs index f446f9cb6..c032a7b73 100644 --- a/runtime/rust/prompty/tests/model/pipeline/model_reconciliation_state_test.rs +++ b/runtime/rust/prompty/tests/model/pipeline/model_reconciliation_state_test.rs @@ -17,7 +17,17 @@ fn test_model_reconciliation_state_load_json() { let json = r####" { "invocationId": "inv_abc123", - "message": "provider connection dropped after request was sent" + "message": "provider connection dropped after request was sent", + "request": { + "context": { + "id": "context:inv_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "invocationId": "inv_abc123", + "iteration": 1, + "contextState": {} + } + } } "####; let ctx = LoadContext::default(); @@ -40,6 +50,14 @@ fn test_model_reconciliation_state_load_yaml() { let yaml = r####" invocationId: inv_abc123 message: provider connection dropped after request was sent +request: + context: + id: "context:inv_abc123" + sessionId: sess_abc123 + turnId: turn_abc123 + invocationId: inv_abc123 + iteration: 1 + contextState: {} "####; let ctx = LoadContext::default(); @@ -62,7 +80,17 @@ fn test_model_reconciliation_state_roundtrip() { let json = r####" { "invocationId": "inv_abc123", - "message": "provider connection dropped after request was sent" + "message": "provider connection dropped after request was sent", + "request": { + "context": { + "id": "context:inv_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "invocationId": "inv_abc123", + "iteration": 1, + "contextState": {} + } + } } "####; let load_ctx = LoadContext::default(); @@ -83,7 +111,17 @@ fn test_model_reconciliation_state_serde_roundtrip() { let json = r####" { "invocationId": "inv_abc123", - "message": "provider connection dropped after request was sent" + "message": "provider connection dropped after request was sent", + "request": { + "context": { + "id": "context:inv_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "invocationId": "inv_abc123", + "iteration": 1, + "contextState": {} + } + } } "####; let instance: ModelReconciliationState = diff --git a/runtime/rust/prompty/tests/model/pipeline/model_tool_request_test.rs b/runtime/rust/prompty/tests/model/pipeline/model_tool_request_test.rs index 77ccd9a87..1d3e255a8 100644 --- a/runtime/rust/prompty/tests/model/pipeline/model_tool_request_test.rs +++ b/runtime/rust/prompty/tests/model/pipeline/model_tool_request_test.rs @@ -94,10 +94,6 @@ fn test_model_tool_request_serde_roundtrip() { ModelToolRequest::load_from_value(&canonical, &LoadContext::default()), "serde deserialize must equal canonical load_from_value" ); - assert_eq!( - value, canonical, - "serde must serialize to byte-identical canonical wire (empty-omission preserved; no plain-derive divergence)" - ); let reparsed: ModelToolRequest = serde_json::from_value(value).expect("serde should re-deserialize"); assert_eq!(instance, reparsed, "serde round-trip must be stable"); diff --git a/runtime/rust/prompty/tests/model/pipeline/resume_context_test.rs b/runtime/rust/prompty/tests/model/pipeline/resume_context_test.rs index 3d2d3f2f7..b717e42c2 100644 --- a/runtime/rust/prompty/tests/model/pipeline/resume_context_test.rs +++ b/runtime/rust/prompty/tests/model/pipeline/resume_context_test.rs @@ -16,7 +16,16 @@ use prompty::model::context::{LoadContext, SaveContext}; fn test_resume_context_load_json() { let json = r####" { - "lastJournalSequence": 12 + "lastJournalSequence": 12, + "checkpoint": { + "id": "ckpt_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "runId": "run_abc123", + "iteration": 1, + "lastSequence": 1, + "contextState": {} + } } "####; let ctx = LoadContext::default(); @@ -34,6 +43,14 @@ fn test_resume_context_load_json() { fn test_resume_context_load_yaml() { let yaml = r####" lastJournalSequence: 12 +checkpoint: + id: ckpt_abc123 + sessionId: sess_abc123 + turnId: turn_abc123 + runId: run_abc123 + iteration: 1 + lastSequence: 1 + contextState: {} "####; let ctx = LoadContext::default(); @@ -51,7 +68,16 @@ lastJournalSequence: 12 fn test_resume_context_roundtrip() { let json = r####" { - "lastJournalSequence": 12 + "lastJournalSequence": 12, + "checkpoint": { + "id": "ckpt_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "runId": "run_abc123", + "iteration": 1, + "lastSequence": 1, + "contextState": {} + } } "####; let load_ctx = LoadContext::default(); @@ -71,7 +97,16 @@ fn test_resume_context_roundtrip() { fn test_resume_context_serde_roundtrip() { let json = r####" { - "lastJournalSequence": 12 + "lastJournalSequence": 12, + "checkpoint": { + "id": "ckpt_abc123", + "sessionId": "sess_abc123", + "turnId": "turn_abc123", + "runId": "run_abc123", + "iteration": 1, + "lastSequence": 1, + "contextState": {} + } } "####; let instance: ResumeContext = diff --git a/runtime/rust/prompty/tests/model/pipeline/run_turn_request_test.rs b/runtime/rust/prompty/tests/model/pipeline/run_turn_request_test.rs index 92ed3e41d..3c86fdc38 100644 --- a/runtime/rust/prompty/tests/model/pipeline/run_turn_request_test.rs +++ b/runtime/rust/prompty/tests/model/pipeline/run_turn_request_test.rs @@ -94,10 +94,6 @@ fn test_run_turn_request_serde_roundtrip() { RunTurnRequest::load_from_value(&canonical, &LoadContext::default()), "serde deserialize must equal canonical load_from_value" ); - assert_eq!( - value, canonical, - "serde must serialize to byte-identical canonical wire (empty-omission preserved; no plain-derive divergence)" - ); let reparsed: RunTurnRequest = serde_json::from_value(value).expect("serde should re-deserialize"); assert_eq!(instance, reparsed, "serde round-trip must be stable"); diff --git a/runtime/rust/prompty/tests/model/pipeline/turn_commit_test.rs b/runtime/rust/prompty/tests/model/pipeline/turn_commit_test.rs index e08adb7c9..c7d71efb5 100644 --- a/runtime/rust/prompty/tests/model/pipeline/turn_commit_test.rs +++ b/runtime/rust/prompty/tests/model/pipeline/turn_commit_test.rs @@ -18,7 +18,8 @@ fn test_turn_commit_load_json() { let json = r####" { "sessionId": "sess_abc123", - "turnId": "turn_abc123" + "turnId": "turn_abc123", + "contextState": {} } "####; let ctx = LoadContext::default(); @@ -38,6 +39,7 @@ fn test_turn_commit_load_yaml() { let yaml = r####" sessionId: sess_abc123 turnId: turn_abc123 +contextState: {} "####; let ctx = LoadContext::default(); @@ -57,7 +59,8 @@ fn test_turn_commit_roundtrip() { let json = r####" { "sessionId": "sess_abc123", - "turnId": "turn_abc123" + "turnId": "turn_abc123", + "contextState": {} } "####; let load_ctx = LoadContext::default(); @@ -78,7 +81,8 @@ fn test_turn_commit_serde_roundtrip() { let json = r####" { "sessionId": "sess_abc123", - "turnId": "turn_abc123" + "turnId": "turn_abc123", + "contextState": {} } "####; let instance: TurnCommit = diff --git a/runtime/rust/prompty/tests/model/pipeline/turn_options_test.rs b/runtime/rust/prompty/tests/model/pipeline/turn_options_test.rs index c3b9e57cb..5ca1d6d50 100644 --- a/runtime/rust/prompty/tests/model/pipeline/turn_options_test.rs +++ b/runtime/rust/prompty/tests/model/pipeline/turn_options_test.rs @@ -159,10 +159,6 @@ fn test_turn_options_serde_roundtrip() { TurnOptions::load_from_value(&canonical, &LoadContext::default()), "serde deserialize must equal canonical load_from_value" ); - assert_eq!( - value, canonical, - "serde must serialize to byte-identical canonical wire (empty-omission preserved; no plain-derive divergence)" - ); let reparsed: TurnOptions = serde_json::from_value(value).expect("serde should re-deserialize"); assert_eq!(instance, reparsed, "serde round-trip must be stable"); } diff --git a/runtime/rust/prompty/tests/model/template/format_config_test.rs b/runtime/rust/prompty/tests/model/template/format_config_test.rs index 34a891085..8c6d72124 100644 --- a/runtime/rust/prompty/tests/model/template/format_config_test.rs +++ b/runtime/rust/prompty/tests/model/template/format_config_test.rs @@ -71,5 +71,10 @@ fn test_format_config_from_format() { let value = serde_json::json!("example"); let ctx = LoadContext::default(); let instance = FormatConfig::load_from_value(&value, &ctx); - let _ = instance; // abstract type, load succeeded + let saved = instance.to_value(&SaveContext::default()); + let reloaded = FormatConfig::load_from_value(&saved, &ctx); + assert_eq!( + reloaded, instance, + "scalar-coerced abstract models must survive save/reload" + ); } diff --git a/runtime/rust/prompty/tests/model/template/parser_config_test.rs b/runtime/rust/prompty/tests/model/template/parser_config_test.rs index 1785b6ce5..1596a0c1a 100644 --- a/runtime/rust/prompty/tests/model/template/parser_config_test.rs +++ b/runtime/rust/prompty/tests/model/template/parser_config_test.rs @@ -68,5 +68,10 @@ fn test_parser_config_from_parser() { let value = serde_json::json!("example"); let ctx = LoadContext::default(); let instance = ParserConfig::load_from_value(&value, &ctx); - let _ = instance; // abstract type, load succeeded + let saved = instance.to_value(&SaveContext::default()); + let reloaded = ParserConfig::load_from_value(&saved, &ctx); + assert_eq!( + reloaded, instance, + "scalar-coerced abstract models must survive save/reload" + ); } diff --git a/runtime/rust/prompty/tests/model/tools/tool_context_test.rs b/runtime/rust/prompty/tests/model/tools/tool_context_test.rs index 842e53582..e4c480fc8 100644 --- a/runtime/rust/prompty/tests/model/tools/tool_context_test.rs +++ b/runtime/rust/prompty/tests/model/tools/tool_context_test.rs @@ -18,7 +18,21 @@ fn test_tool_context_load_json() { { "metadata": { "userId": "user-123" - } + }, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "value": "Hello!" + } + ], + "metadata": { + "source": "user-input" + } + } + ] } "####; let ctx = LoadContext::default(); @@ -37,6 +51,13 @@ fn test_tool_context_load_yaml() { let yaml = r####" metadata: userId: user-123 +messages: + - role: user + parts: + - kind: text + value: Hello! + metadata: + source: user-input "####; let ctx = LoadContext::default(); @@ -56,7 +77,21 @@ fn test_tool_context_roundtrip() { { "metadata": { "userId": "user-123" - } + }, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "value": "Hello!" + } + ], + "metadata": { + "source": "user-input" + } + } + ] } "####; let load_ctx = LoadContext::default(); @@ -78,7 +113,21 @@ fn test_tool_context_serde_roundtrip() { { "metadata": { "userId": "user-123" - } + }, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "value": "Hello!" + } + ], + "metadata": { + "source": "user-input" + } + } + ] } "####; let instance: ToolContext = @@ -95,6 +144,10 @@ fn test_tool_context_serde_roundtrip() { ToolContext::load_from_value(&canonical, &LoadContext::default()), "serde deserialize must equal canonical load_from_value" ); + assert_eq!( + value, canonical, + "serde must serialize to byte-identical canonical wire (empty-omission preserved; no plain-derive divergence)" + ); let reparsed: ToolContext = serde_json::from_value(value).expect("serde should re-deserialize"); assert_eq!(instance, reparsed, "serde round-trip must be stable"); } diff --git a/runtime/rust/prompty/tests/model/tracing/trace_file_test.rs b/runtime/rust/prompty/tests/model/tracing/trace_file_test.rs index cd0b5eecf..8d353957e 100644 --- a/runtime/rust/prompty/tests/model/tracing/trace_file_test.rs +++ b/runtime/rust/prompty/tests/model/tracing/trace_file_test.rs @@ -17,7 +17,17 @@ fn test_trace_file_load_json() { let json = r####" { "runtime": "python", - "version": "2.0.0" + "version": "2.0.0", + "trace": { + "name": "prompty.core.pipeline.run", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + }, + "signature": "prompty.core.pipeline.run", + "error": "Connection refused" + } } "####; let ctx = LoadContext::default(); @@ -37,6 +47,14 @@ fn test_trace_file_load_yaml() { let yaml = r####" runtime: python version: 2.0.0 +trace: + name: prompty.core.pipeline.run + __time: + start: "2026-04-04T12:00:00Z" + end: "2026-04-04T12:00:01Z" + duration: 1000 + signature: prompty.core.pipeline.run + error: Connection refused "####; let ctx = LoadContext::default(); @@ -56,7 +74,17 @@ fn test_trace_file_roundtrip() { let json = r####" { "runtime": "python", - "version": "2.0.0" + "version": "2.0.0", + "trace": { + "name": "prompty.core.pipeline.run", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + }, + "signature": "prompty.core.pipeline.run", + "error": "Connection refused" + } } "####; let load_ctx = LoadContext::default(); @@ -77,7 +105,17 @@ fn test_trace_file_serde_roundtrip() { let json = r####" { "runtime": "python", - "version": "2.0.0" + "version": "2.0.0", + "trace": { + "name": "prompty.core.pipeline.run", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + }, + "signature": "prompty.core.pipeline.run", + "error": "Connection refused" + } } "####; let instance: TraceFile = diff --git a/runtime/rust/prompty/tests/model/tracing/trace_span_test.rs b/runtime/rust/prompty/tests/model/tracing/trace_span_test.rs index 2ac0e6a62..f488df3c9 100644 --- a/runtime/rust/prompty/tests/model/tracing/trace_span_test.rs +++ b/runtime/rust/prompty/tests/model/tracing/trace_span_test.rs @@ -18,7 +18,12 @@ fn test_trace_span_load_json() { { "name": "prompty.core.pipeline.run", "signature": "prompty.core.pipeline.run", - "error": "Connection refused" + "error": "Connection refused", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + } } "####; let ctx = LoadContext::default(); @@ -48,6 +53,10 @@ fn test_trace_span_load_yaml() { name: prompty.core.pipeline.run signature: prompty.core.pipeline.run error: Connection refused +__time: + start: "2026-04-04T12:00:00Z" + end: "2026-04-04T12:00:01Z" + duration: 1000 "####; let ctx = LoadContext::default(); @@ -72,7 +81,12 @@ fn test_trace_span_roundtrip() { { "name": "prompty.core.pipeline.run", "signature": "prompty.core.pipeline.run", - "error": "Connection refused" + "error": "Connection refused", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + } } "####; let load_ctx = LoadContext::default(); @@ -94,7 +108,12 @@ fn test_trace_span_serde_roundtrip() { { "name": "prompty.core.pipeline.run", "signature": "prompty.core.pipeline.run", - "error": "Connection refused" + "error": "Connection refused", + "__time": { + "start": "2026-04-04T12:00:00Z", + "end": "2026-04-04T12:00:01Z", + "duration": 1000 + } } "####; let instance: TraceSpan = diff --git a/runtime/rust/prompty/tests/model/wire/anthropic_messages_request_test.rs b/runtime/rust/prompty/tests/model/wire/anthropic_messages_request_test.rs index cdffdb621..f096b22f0 100644 --- a/runtime/rust/prompty/tests/model/wire/anthropic_messages_request_test.rs +++ b/runtime/rust/prompty/tests/model/wire/anthropic_messages_request_test.rs @@ -24,6 +24,12 @@ fn test_anthropic_messages_request_load_json() { "top_k": 40, "stop_sequences": [ "\n\nHuman:" + ], + "messages": [ + { + "role": "user", + "content": [] + } ] } "####; @@ -64,6 +70,9 @@ top_p: 0.9 top_k: 40 stop_sequences: - "\n\nHuman:" +messages: + - role: user + content: [] "####; let ctx = LoadContext::default(); @@ -97,6 +106,12 @@ fn test_anthropic_messages_request_roundtrip() { "top_k": 40, "stop_sequences": [ "\n\nHuman:" + ], + "messages": [ + { + "role": "user", + "content": [] + } ] } "####; @@ -125,6 +140,12 @@ fn test_anthropic_messages_request_serde_roundtrip() { "top_k": 40, "stop_sequences": [ "\n\nHuman:" + ], + "messages": [ + { + "role": "user", + "content": [] + } ] } "####; diff --git a/runtime/rust/prompty/tests/model/wire/anthropic_messages_response_test.rs b/runtime/rust/prompty/tests/model/wire/anthropic_messages_response_test.rs index c1174434c..971874b60 100644 --- a/runtime/rust/prompty/tests/model/wire/anthropic_messages_response_test.rs +++ b/runtime/rust/prompty/tests/model/wire/anthropic_messages_response_test.rs @@ -18,7 +18,11 @@ fn test_anthropic_messages_response_load_json() { { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn" + "stop_reason": "end_turn", + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } } "####; let ctx = LoadContext::default(); @@ -40,6 +44,9 @@ fn test_anthropic_messages_response_load_yaml() { id: msg_01XFDUDYJgAACzvnptvVoYEL model: claude-sonnet-4-20250514 stop_reason: end_turn +usage: + input_tokens: 150 + output_tokens: 42 "####; let ctx = LoadContext::default(); @@ -61,7 +68,11 @@ fn test_anthropic_messages_response_roundtrip() { { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn" + "stop_reason": "end_turn", + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } } "####; let load_ctx = LoadContext::default(); @@ -83,7 +94,11 @@ fn test_anthropic_messages_response_serde_roundtrip() { { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn" + "stop_reason": "end_turn", + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } } "####; let instance: AnthropicMessagesResponse = diff --git a/runtime/rust/prompty/tests/named_collection_vectors.rs b/runtime/rust/prompty/tests/named_collection_vectors.rs new file mode 100644 index 000000000..e5e4eac92 --- /dev/null +++ b/runtime/rust/prompty/tests/named_collection_vectors.rs @@ -0,0 +1,395 @@ +//! Named-collection roundtrip tests backed by the shared model vectors. + +use std::path::PathBuf; + +use prompty::model::Prompty; +use prompty::model::context::{LoadContext, SaveContext}; +use serde_json::{Map, Value}; + +fn vectors_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("runtime/rust/prompty must have a rust parent") + .parent() + .expect("runtime/rust must have a runtime parent") + .parent() + .expect("runtime must have a repository parent") + .join("spec") + .join("vectors") + .join("model") + .join("named_collection_vectors.json") +} + +fn semantic_entries(collection: &Value) -> Vec { + match collection { + Value::Array(entries) => entries + .iter() + .map(|entry| { + let mut entry = entry + .as_object() + .expect("array-form named collection entries must be objects") + .clone(); + entry + .entry("name".to_string()) + .or_insert_with(|| Value::String(String::new())); + Value::Object(entry) + }) + .collect(), + Value::Object(entries) => entries + .iter() + .map(|(name, entry)| { + let mut entry = entry + .as_object() + .expect("object-form named collection entries must be objects") + .clone(); + entry.insert("name".to_string(), Value::String(name.clone())); + Value::Object(entry) + }) + .collect(), + value => panic!("named collection must be an array or object, got {value:?}"), + } +} + +fn assert_subset(actual: &Value, expected: &Value, path: &str) { + match expected { + Value::Object(expected) => { + let actual = actual + .as_object() + .unwrap_or_else(|| panic!("[{path}] expected object, got {actual:?}")); + for (key, expected_value) in expected { + let actual_value = actual + .get(key) + .unwrap_or_else(|| panic!("[{path}] missing expected key {key:?}")); + assert_subset(actual_value, expected_value, &format!("{path}.{key}")); + } + } + Value::Array(expected) => { + let actual = actual + .as_array() + .unwrap_or_else(|| panic!("[{path}] expected array, got {actual:?}")); + assert_eq!( + actual.len(), + expected.len(), + "[{path}] array length changed" + ); + for (index, expected_value) in expected.iter().enumerate() { + assert_subset(&actual[index], expected_value, &format!("{path}[{index}]")); + } + } + expected => assert_eq!(actual, expected, "[{path}] value changed"), + } +} + +fn assert_collection(vector_name: &str, collection: &Value, expected: &Value) { + let expected_format = expected["collectionFormat"] + .as_str() + .expect("roundtrip vector must declare collectionFormat"); + assert_eq!( + if collection.is_array() { + "array" + } else if collection.is_object() { + "object" + } else { + "invalid" + }, + expected_format, + "[{vector_name}] canonical collection format changed" + ); + + if let Some(wire_entries) = expected["wireEntries"].as_array() { + let entries = collection + .as_array() + .unwrap_or_else(|| panic!("[{vector_name}] wire entry assertions require array form")); + for assertion in wire_entries { + let index = assertion["index"] + .as_u64() + .expect("wire entry assertion must declare an index") + as usize; + let entry = entries + .get(index) + .unwrap_or_else(|| panic!("[{vector_name}] missing wire entry at index {index}")) + .as_object() + .unwrap_or_else(|| { + panic!("[{vector_name}] wire entry at index {index} must be an object") + }); + for field in assertion["absentFields"] + .as_array() + .expect("wire entry assertion must declare absentFields") + { + let field = field.as_str().expect("wire absent field must be a string"); + assert!( + !entry.contains_key(field), + "[{vector_name}] wire entry {index} unexpectedly serialized field {field:?}" + ); + } + } + } + + let actual_entries = semantic_entries(collection); + let expected_entries = expected["entries"] + .as_array() + .expect("roundtrip vector must declare entries"); + assert_eq!( + actual_entries.len(), + expected_entries.len(), + "[{vector_name}] named collection entry count changed" + ); + if let Some(absent_fields) = expected["absentEntryFields"].as_array() { + for entry in &actual_entries { + for field in absent_fields { + let field = field.as_str().expect("absent entry field must be a string"); + assert!( + entry.get(field).is_none(), + "[{vector_name}] entry {:?} unexpectedly populated field {field:?}", + entry["name"] + ); + } + } + } + + if expected["preserveOrder"].as_bool() == Some(true) { + for (index, expected_entry) in expected_entries.iter().enumerate() { + assert_subset( + &actual_entries[index], + expected_entry, + &format!("{vector_name}.entries[{index}]"), + ); + } + } else { + let actual_by_name: Map = actual_entries + .into_iter() + .map(|entry| { + let name = entry["name"] + .as_str() + .expect("semantic entry name must be a string") + .to_string(); + (name, entry) + }) + .collect(); + for expected_entry in expected_entries { + let name = expected_entry["name"] + .as_str() + .expect("expected entry name must be a string"); + let actual_entry = actual_by_name + .get(name) + .unwrap_or_else(|| panic!("[{vector_name}] missing named entry {name:?}")); + assert_subset( + actual_entry, + expected_entry, + &format!("{vector_name}.entries.{name}"), + ); + } + } +} + +fn vectors() -> Vec { + let raw = + std::fs::read_to_string(vectors_path()).expect("failed to read named collection vectors"); + let document: Value = + serde_json::from_str(&raw).expect("failed to parse named collection vectors"); + document["vectors"] + .as_array() + .expect("named collection vectors must contain a vectors array") + .clone() +} + +#[test] +fn named_collection_roundtrip_vectors() { + for vector in vectors() + .into_iter() + .filter(|vector| vector["operation"] == "load-save-reload") + { + let name = vector["name"] + .as_str() + .expect("vector name must be a string"); + let json = serde_json::to_string(&vector["input"]) + .expect("named collection vector input must be JSON-compatible"); + let result = Prompty::from_json(&json, &LoadContext::default()); + + let loaded = + result.unwrap_or_else(|error| panic!("[{name}] valid collection failed: {error}")); + let saved = loaded.to_value(&SaveContext::default()); + let collection_path = vector["collectionPath"] + .as_str() + .expect("roundtrip vector must declare collectionPath"); + let collection = saved + .get(collection_path) + .unwrap_or_else(|| panic!("[{name}] missing collection {collection_path:?}")); + assert_collection(name, collection, &vector["expected"]); + + let saved_json = + serde_json::to_string(&saved).expect("saved named collection must be JSON-compatible"); + let reloaded = Prompty::from_json(&saved_json, &LoadContext::default()) + .unwrap_or_else(|error| panic!("[{name}] saved collection failed: {error}")); + let resaved = reloaded.to_value(&SaveContext::default()); + let reloaded_collection = resaved + .get(collection_path) + .unwrap_or_else(|| panic!("[{name}] reload lost collection {collection_path:?}")); + assert_collection(name, reloaded_collection, &vector["expected"]); + } +} + +#[test] +fn unnamed_composite_omits_empty_name_stably() { + let vector_name = "unnamed_composite_omits_empty_name_stably"; + let vector = vectors() + .into_iter() + .find(|vector| vector["name"] == vector_name) + .expect("missing unnamed composite vector"); + let json = serde_json::to_string(&vector["input"]) + .expect("unnamed composite vector input must be JSON-compatible"); + let loaded = Prompty::from_json(&json, &LoadContext::default()) + .unwrap_or_else(|error| panic!("[{vector_name}] valid collection failed: {error}")); + + let loaded_inputs = loaded + .inputs + .as_ref() + .unwrap_or_else(|| panic!("[{vector_name}] load lost inputs")); + assert_eq!( + loaded_inputs.len(), + 1, + "[{vector_name}] load changed the entry count" + ); + assert_eq!( + loaded_inputs[0].name, "", + "[{vector_name}] absent wire name did not materialize as an empty in-memory name" + ); + + let saved = loaded.to_value(&SaveContext::default()); + let collection = saved + .get("inputs") + .expect("[unnamed_composite_omits_empty_name_stably] first save lost inputs"); + assert_collection(vector_name, collection, &vector["expected"]); + + let saved_json = serde_json::to_string(&saved).expect("first save must remain JSON-compatible"); + let reloaded = Prompty::from_json(&saved_json, &LoadContext::default()) + .unwrap_or_else(|error| panic!("[{vector_name}] first save failed to reload: {error}")); + let reloaded_inputs = reloaded + .inputs + .as_ref() + .unwrap_or_else(|| panic!("[{vector_name}] reload lost inputs")); + assert_eq!( + reloaded_inputs[0].name, "", + "[{vector_name}] reload changed the unnamed in-memory state" + ); + let resaved = reloaded.to_value(&SaveContext::default()); + let reloaded_collection = resaved + .get("inputs") + .expect("[unnamed_composite_omits_empty_name_stably] reload/save lost inputs"); + assert_collection(vector_name, reloaded_collection, &vector["expected"]); +} + +#[test] +fn name_keyed_property_scalars_infer_kind_and_default_without_degradation() { + let vector_names = [ + "string_scalar_in_name_keyed_inputs_infers_property", + "integer_scalar_in_name_keyed_inputs_infers_property", + "float_scalar_in_name_keyed_inputs_infers_property", + "boolean_scalar_in_name_keyed_inputs_infers_property", + ]; + let all_vectors = vectors(); + let mut failures = Vec::new(); + + for vector_name in vector_names { + let vector = all_vectors + .iter() + .find(|candidate| candidate["name"] == vector_name) + .unwrap_or_else(|| panic!("missing named collection vector {vector_name}")); + let json = serde_json::to_string(&vector["input"]) + .expect("named collection vector input must be JSON-compatible"); + let loaded = match Prompty::from_json(&json, &LoadContext::default()) { + Ok(loaded) => loaded, + Err(error) => { + failures.push(format!("[{vector_name}] valid scalar failed: {error}")); + continue; + } + }; + let saved = loaded.to_value(&SaveContext::default()); + let collection_path = vector["collectionPath"] + .as_str() + .expect("scalar vector must declare collectionPath"); + let collection = match saved.get(collection_path) { + Some(collection) => collection, + None => { + failures.push(format!( + "[{vector_name}] missing saved collection {collection_path:?}" + )); + continue; + } + }; + let actual_entries = semantic_entries(collection); + let expected_entry = &vector["expected"]["entries"][0]; + let expected_name = expected_entry["name"] + .as_str() + .expect("expected scalar entry name must be a string"); + let actual_entry = match actual_entries + .iter() + .find(|entry| entry["name"] == expected_name) + { + Some(entry) => entry, + None => { + failures.push(format!( + "[{vector_name}] missing scalar entry {expected_name:?}" + )); + continue; + } + }; + + let expected_kind = &expected_entry["kind"]; + if actual_entry["kind"].as_str().unwrap_or_default().is_empty() { + failures.push(format!("[{vector_name}] silently produced an empty kind")); + } else if actual_entry["kind"] != *expected_kind { + failures.push(format!( + "[{vector_name}] expected kind {expected_kind}, got {}", + actual_entry["kind"] + )); + } + if actual_entry["default"] != expected_entry["default"] { + failures.push(format!( + "[{vector_name}] expected default {}, got {}", + expected_entry["default"], actual_entry["default"] + )); + } + if let Some(example) = actual_entry.get("example") { + failures.push(format!( + "[{vector_name}] collection shorthand unexpectedly populated example {}", + example + )); + } + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn named_collection_rejection_vectors() { + for vector in vectors() + .into_iter() + .filter(|vector| vector["operation"] == "load-error") + { + let name = vector["name"] + .as_str() + .expect("vector name must be a string"); + let json = serde_json::to_string(&vector["input"]) + .expect("named collection vector input must be JSON-compatible"); + let error = match Prompty::from_json(&json, &LoadContext::default()) { + Ok(_) => panic!("[{name}] invalid nested array was accepted"), + Err(error) => error, + }; + let diagnostic = error.to_string(); + let expected_path = vector["expected"]["path"] + .as_str() + .expect("error vector must declare path"); + let value_category = vector["expected"]["valueCategory"] + .as_str() + .expect("error vector must declare valueCategory"); + assert!( + diagnostic.contains(expected_path), + "[{name}] diagnostic did not identify path {expected_path:?}: {diagnostic}" + ); + assert!( + diagnostic.contains(value_category), + "[{name}] diagnostic did not identify category {value_category:?}: {diagnostic}" + ); + } +} diff --git a/runtime/rust/prompty/tests/property_scalar_coercion_vectors.rs b/runtime/rust/prompty/tests/property_scalar_coercion_vectors.rs new file mode 100644 index 000000000..2094156f1 --- /dev/null +++ b/runtime/rust/prompty/tests/property_scalar_coercion_vectors.rs @@ -0,0 +1,68 @@ +//! Cross-runtime Property scalar coercion tests backed by the shared model vectors. + +use std::path::PathBuf; + +use prompty::model::Property; +use prompty::model::context::LoadContext; +use serde_json::Value; + +fn vectors_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("runtime/rust/prompty must have a rust parent") + .parent() + .expect("runtime/rust must have a runtime parent") + .parent() + .expect("runtime must have a repository parent") + .join("spec") + .join("vectors") + .join("model") + .join("property_scalar_coercion_vectors.json") +} + +#[test] +fn all_primitive_property_scalars_coerce_atomically() { + let raw = std::fs::read_to_string(vectors_path()) + .expect("failed to read Property scalar coercion vectors"); + let document: Value = + serde_json::from_str(&raw).expect("failed to parse Property scalar coercion vectors"); + let vector = &document["vectors"][0]; + assert_eq!( + vector["name"], "all_primitive_property_scalars_coerce_atomically", + "unexpected Property scalar coercion vector" + ); + assert_eq!(vector["operation"], "load"); + + let cases = vector["cases"] + .as_array() + .expect("Property scalar coercion vector must contain cases"); + let case_names: Vec<&str> = cases + .iter() + .map(|case| case["name"].as_str().expect("scalar case must have a name")) + .collect(); + assert_eq!(case_names, ["string", "integer", "float", "boolean"]); + + let context = LoadContext::default(); + let mut failures = Vec::new(); + for case in cases { + let case_name = case["name"].as_str().expect("scalar case must have a name"); + let loaded = Property::load_from_value(&case["input"], &context); + let expected_kind = case["expected"]["kind"] + .as_str() + .expect("expected kind must be a string"); + if loaded.kind_str() != expected_kind { + failures.push(format!( + "[{case_name}] expected kind {expected_kind:?}, got {:?}", + loaded.kind_str() + )); + continue; + } + if loaded.example.as_ref() != Some(&case["expected"]["example"]) { + failures.push(format!( + "[{case_name}] expected example {}, got {:?}", + case["expected"]["example"], loaded.example + )); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} diff --git a/runtime/rust/prompty/tests/record_unknown_nullability_vectors.rs b/runtime/rust/prompty/tests/record_unknown_nullability_vectors.rs new file mode 100644 index 000000000..c5f775e43 --- /dev/null +++ b/runtime/rust/prompty/tests/record_unknown_nullability_vectors.rs @@ -0,0 +1,87 @@ +//! Record nullability tests backed by the shared model vectors. + +use std::path::PathBuf; + +use prompty::model::context::{LoadContext, SaveContext}; +use prompty::model::{ + HostToolRequest, Message, ModelInfo, Prompty, RunTurnRequest, SessionEvent, TurnEvent, + TurnModelRequest, TurnModelResponse, +}; +use serde_json::Value; + +fn vectors_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("runtime/rust/prompty must have a rust parent") + .parent() + .expect("runtime/rust must have a runtime parent") + .parent() + .expect("runtime must have a repository parent") + .join("spec") + .join("vectors") + .join("model") + .join("record_unknown_nullability_vectors.json") +} + +macro_rules! roundtrip { + ($model:ty, $json:expr) => {{ + let loaded = + <$model>::from_json($json, &LoadContext::default()).expect("vector input must load"); + let saved = loaded.to_value(&SaveContext::default()); + let saved_json = + serde_json::to_string(&saved).expect("saved model must be JSON-compatible"); + let reloaded = <$model>::from_json(&saved_json, &LoadContext::default()) + .expect("saved model must reload"); + reloaded.to_value(&SaveContext::default()) + }}; +} + +#[test] +fn record_unknown_nullability_vectors() { + let raw = std::fs::read_to_string(vectors_path()) + .expect("failed to read Record nullability vectors"); + let document: Value = + serde_json::from_str(&raw).expect("failed to parse Record nullability vectors"); + let vectors = document["vectors"] + .as_array() + .expect("Record nullability vectors must contain a vectors array"); + + for vector in vectors { + let name = vector["name"] + .as_str() + .expect("vector name must be a string"); + assert_eq!( + vector["operation"], "load-save-reload", + "[{name}] unsupported vector operation" + ); + let model = vector["model"] + .as_str() + .expect("vector model must be a string"); + let field_path = vector["fieldPath"] + .as_str() + .expect("vector fieldPath must be a string"); + let json = + serde_json::to_string(&vector["input"]).expect("vector input must be JSON-compatible"); + + let resaved = match model { + "Message" => roundtrip!(Message, &json), + "Prompty" => roundtrip!(Prompty, &json), + "ModelInfo" => roundtrip!(ModelInfo, &json), + "TurnModelRequest" => roundtrip!(TurnModelRequest, &json), + "RunTurnRequest" => roundtrip!(RunTurnRequest, &json), + "TurnModelResponse" => roundtrip!(TurnModelResponse, &json), + "HostToolRequest" => roundtrip!(HostToolRequest, &json), + "TurnEvent" => roundtrip!(TurnEvent, &json), + "SessionEvent" => roundtrip!(SessionEvent, &json), + _ => panic!("[{name}] unsupported model {model:?}"), + }; + + let actual = resaved + .get(field_path) + .unwrap_or_else(|| panic!("[{name}] reload lost field {field_path:?}")); + assert_eq!( + actual, &vector["expected"], + "[{name}] reload changed null-valued record entries" + ); + } +} diff --git a/runtime/typescript/.env.example b/runtime/typescript/.env.example index 1397eafac..df366b452 100644 --- a/runtime/typescript/.env.example +++ b/runtime/typescript/.env.example @@ -9,7 +9,10 @@ OPENAI_API_KEY= OPENAI_BASE_URL= OPENAI_MODEL=gpt-4o-mini OPENAI_EMBEDDING_MODEL=text-embedding-3-small -OPENAI_IMAGE_MODEL=dall-e-2 +# Image generation is opt-in and billable; leave blank to skip those tests. +# Model availability is account-specific: dall-e-2 / dall-e-3 are retired on +# current accounts (400 "does not exist"); newer accounts expose gpt-image-1. +OPENAI_IMAGE_MODEL= # Direct OpenAI (api.openai.com — no proxy/compat layer) DIRECT_OPENAI_API_KEY= diff --git a/runtime/typescript/packages/anthropic/tests/wire-vectors.test.ts b/runtime/typescript/packages/anthropic/tests/wire-vectors.test.ts new file mode 100644 index 000000000..7e53ab8c9 --- /dev/null +++ b/runtime/typescript/packages/anthropic/tests/wire-vectors.test.ts @@ -0,0 +1,109 @@ +/** + * Wire format vector tests — validate against shared spec vectors. + * + * Reads `spec/vectors/wire/wire_vectors.json` and asserts that this package's + * request-body construction matches the canonical expectation for every + * Anthropic-provider vector. + * + * Ported from the Rust reference implementation at + * `runtime/rust/prompty-anthropic/tests/vectors.rs`. + * + * Selection is data-driven rather than a hand-maintained list of names, so a + * newly added Anthropic vector is executed automatically instead of silently + * going unported. The count assertion below is the guard against the opposite + * failure — a vector disappearing without anyone noticing. + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { Message, Prompty } from "@prompty/core"; +import type { ContentPart } from "@prompty/core"; +import { describe, expect, it } from "vitest"; + +import { buildChatArgs } from "../src/wire.js"; + +interface WireVector { + name: string; + description: string; + input: { + provider?: string; + apiType?: string; + model_id?: string; + messages: { role: string; content: { kind: string; value: string; mediaType?: string }[] }[]; + tools?: unknown[]; + options?: Record; + outputs?: unknown[]; + }; + expected: { request_body: Record }; +} + +const VECTOR_PATH = resolve(import.meta.dirname, "../../../../../spec/vectors/wire/wire_vectors.json"); + +const allVectors: WireVector[] = JSON.parse(readFileSync(VECTOR_PATH, "utf8")); +const vectors = allVectors.filter((v) => v.input.provider === "anthropic"); + +/** Build Message objects from the vector's message/content description. */ +function buildMessages(input: WireVector["input"]): Message[] { + return input.messages.map((m) => { + const parts: ContentPart[] = m.content.map((p) => { + switch (p.kind) { + case "text": + return { kind: "text", value: p.value } as ContentPart; + case "image": + return { kind: "image", source: p.value, ...(p.mediaType && { mediaType: p.mediaType }) } as ContentPart; + case "audio": + return { kind: "audio", source: p.value, ...(p.mediaType && { mediaType: p.mediaType }) } as ContentPart; + default: + throw new Error(`Unknown content kind: ${p.kind}`); + } + }); + return new Message({ role: m.role as Message["role"], parts }); + }); +} + +/** Build a Prompty agent from the vector's model/tools/options/outputs fields. */ +function buildAgent(input: WireVector["input"]): Prompty { + const data: Record = { + name: "test", + kind: "prompt", + model: { + id: input.model_id ?? "claude-sonnet-4-5-20250929", + apiType: input.apiType ?? "chat", + provider: input.provider ?? "anthropic", + }, + instructions: "test", + }; + + if (input.options && Object.keys(input.options).length > 0) { + (data.model as Record).options = input.options; + } + if (input.tools && input.tools.length > 0) { + data.tools = input.tools; + } + if (input.outputs && input.outputs.length > 0) { + data.outputs = input.outputs; + } + + return Prompty.load(data); +} + +describe("wire vectors (anthropic)", () => { + it("executes every Anthropic vector in the shared spec file", () => { + // Guards against a vector being removed, or the provider filter silently + // matching nothing, either of which would make this suite vacuously green. + expect(vectors.length).toBe(6); + expect(allVectors.length).toBe(29); + }); + + for (const vector of vectors) { + it(`${vector.name} — ${vector.description}`, () => { + const apiType = vector.input.apiType ?? "chat"; + if (apiType !== "chat" && apiType !== "agent") { + throw new Error(`Unsupported apiType for the Anthropic provider: ${apiType}`); + } + const actual = buildChatArgs(buildAgent(vector.input), buildMessages(vector.input)); + expect(actual).toEqual(vector.expected.request_body); + }); + } +}); diff --git a/runtime/typescript/packages/core/src/core/types.ts b/runtime/typescript/packages/core/src/core/types.ts index 0f398f5d9..b9f772b8c 100644 --- a/runtime/typescript/packages/core/src/core/types.ts +++ b/runtime/typescript/packages/core/src/core/types.ts @@ -80,12 +80,12 @@ export class Message implements MessageHelpers { this.metadata = init?.metadata ?? {}; } - /** Concatenate all TextPart values into a single string. */ + /** Concatenate all TextPart values joined by newline. */ get text(): string { return this.parts .filter((p): p is TextPart => p.kind === "text") .map((p) => p.value) - .join(""); + .join("\n"); } /** @@ -94,8 +94,8 @@ export class Message implements MessageHelpers { * - If multimodal, return an array of content objects. */ toTextContent(): string | Record[] { - if (this.parts.length === 1 && this.parts[0].kind === "text") { - return (this.parts[0] as TextPart).value; + if (this.parts.every((part) => part.kind === "text")) { + return this.text; } return this.parts.map(partToWireContent); } diff --git a/runtime/typescript/packages/core/src/harness/turn-runner.ts b/runtime/typescript/packages/core/src/harness/turn-runner.ts index a650c1830..889839ba8 100644 --- a/runtime/typescript/packages/core/src/harness/turn-runner.ts +++ b/runtime/typescript/packages/core/src/harness/turn-runner.ts @@ -185,7 +185,7 @@ export class ReferenceTurnRunner { this.recordTurn("permission_completed", turnId, iteration, decision.save()); if (!decision.approved) { - return new HostToolResult({ + const denied = new HostToolResult({ requestId: toolRequest.requestId, toolCallId: toolRequest.toolCallId, toolName: toolRequest.toolName, @@ -193,6 +193,8 @@ export class ReferenceTurnRunner { errorKind: "permission_denied", result: { message: decision.reason ?? "Permission denied" }, }); + this.recordTurn("tool_result", turnId, iteration, denied.save()); + return denied; } this.recordTurn("tool_execution_start", turnId, iteration, toolRequest.save()); diff --git a/runtime/typescript/packages/core/src/model/agent/guardrail-result.ts b/runtime/typescript/packages/core/src/model/agent/guardrail-result.ts index 7e43c7db1..59ba1c636 100644 --- a/runtime/typescript/packages/core/src/model/agent/guardrail-result.ts +++ b/runtime/typescript/packages/core/src/model/agent/guardrail-result.ts @@ -27,6 +27,7 @@ export class GuardrailResult { data: Record, context?: LoadContext, ): GuardrailResult { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/agent/prompty.ts b/runtime/typescript/packages/core/src/model/agent/prompty.ts index 917005bc2..afd1470b3 100644 --- a/runtime/typescript/packages/core/src/model/agent/prompty.ts +++ b/runtime/typescript/packages/core/src/model/agent/prompty.ts @@ -15,9 +15,9 @@ export class Prompty { displayName?: string | undefined; description?: string | undefined; metadata?: Record | undefined; - inputs?: Property[] = []; - outputs?: Property[] = []; - model!: Model; + inputs?: Property[]; + outputs?: Property[]; + model?: Model | undefined; tools?: Tool[] = []; template?: Template | undefined; instructions?: string | undefined; @@ -42,9 +42,7 @@ export class Prompty { if (init?.model !== undefined) { this.model = init.model; } - if (init?.tools !== undefined) { - this.tools = init.tools; - } + this.tools = init?.tools ?? []; if (init?.template !== undefined) { this.template = init.template; } @@ -56,6 +54,7 @@ export class Prompty { //#region Load Methods static load(data: Record, context?: LoadContext): Prompty { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -77,28 +76,31 @@ export class Prompty { if (data["inputs"] !== undefined && data["inputs"] !== null) { instance.inputs = Prompty.loadInputs( data["inputs"] as unknown[], - context, + context.at("inputs"), ); } if (data["outputs"] !== undefined && data["outputs"] !== null) { instance.outputs = Prompty.loadOutputs( data["outputs"] as unknown[], - context, + context.at("outputs"), ); } if (data["model"] !== undefined && data["model"] !== null) { instance.model = Model.load( data["model"] as Record, - context, + context.at("model"), ); } if (data["tools"] !== undefined && data["tools"] !== null) { - instance.tools = Prompty.loadTools(data["tools"] as unknown[], context); + instance.tools = Prompty.loadTools( + data["tools"] as unknown[], + context.at("tools"), + ); } if (data["template"] !== undefined && data["template"] !== null) { instance.template = Template.load( data["template"] as Record, - context, + context.at("template"), ); } if (data["instructions"] !== undefined && data["instructions"] !== null) { @@ -115,20 +117,43 @@ export class Prompty { data: Record[] | unknown[], context?: LoadContext, ): Property[] { + context ??= new LoadContext({ path: "inputs" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Property[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Property.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, kind: v }); + let shorthand: Record; + if (typeof v === "number" && Number.isInteger(v)) { + shorthand = { kind: "integer", default: v }; + } else if (typeof v === "number") { + shorthand = { kind: "float", default: v }; + } else if (typeof v === "string") { + shorthand = { kind: "string", default: v }; + } else if (typeof v === "boolean") { + shorthand = { kind: "boolean", default: v }; + } else { + shorthand = { default: v }; + } + result.push(Property.load({ name: k, ...shorthand }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Property.load(item as Record, context), + return data.map((item, index) => + Property.load(item as Record, context.atIndex(index)), ); } @@ -140,37 +165,44 @@ export class Prompty { context = new SaveContext(); } + const serialized = items.map( + (item) => ({ ...item.save(context) }) as Record, + ); + for (const itemData of serialized) { + if (itemData["name"] === "") delete itemData["name"]; + } + if (context.collectionFormat === "array") { - return items.map((item) => item.save(context)); + return serialized; + } + + const names = new Set(); + for (const itemData of serialized) { + const name = itemData["name"]; + if (typeof name !== "string" || name.length === 0 || names.has(name)) + return serialized; + names.add(name); } // Object format: use name as key const result: Record = {}; - for (const item of items) { - const itemData = item.save(context) as Record; - const name = itemData["name"] as string | undefined; + for (let index = 0; index < items.length; index++) { + const item = items[index]; + const itemData = serialized[index]; + const name = itemData["name"] as string; delete itemData["name"]; - if (name) { - // Check if we can use shorthand (only primary property set) - const shorthand = (item.constructor as typeof Property) - .shorthandProperty; - if ( - context.useShorthand && - shorthand && - Object.keys(itemData).length === 1 && - shorthand in itemData - ) { - result[name] = itemData[shorthand]; - continue; - } - result[name] = itemData; - } else { - // No name, fall back to array format for this item - if (!result["_unnamed"]) { - result["_unnamed"] = []; - } - (result["_unnamed"] as unknown[]).push(itemData); + // Check if we can use shorthand (only primary property set) + const shorthand = (item.constructor as typeof Property).shorthandProperty; + if ( + context.useShorthand && + shorthand && + Object.keys(itemData).length === 1 && + shorthand in itemData + ) { + result[name] = itemData[shorthand]; + continue; } + result[name] = itemData; } return result; } @@ -179,20 +211,43 @@ export class Prompty { data: Record[] | unknown[], context?: LoadContext, ): Property[] { + context ??= new LoadContext({ path: "outputs" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Property[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Property.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, kind: v }); + let shorthand: Record; + if (typeof v === "number" && Number.isInteger(v)) { + shorthand = { kind: "integer", default: v }; + } else if (typeof v === "number") { + shorthand = { kind: "float", default: v }; + } else if (typeof v === "string") { + shorthand = { kind: "string", default: v }; + } else if (typeof v === "boolean") { + shorthand = { kind: "boolean", default: v }; + } else { + shorthand = { default: v }; + } + result.push(Property.load({ name: k, ...shorthand }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Property.load(item as Record, context), + return data.map((item, index) => + Property.load(item as Record, context.atIndex(index)), ); } @@ -204,37 +259,44 @@ export class Prompty { context = new SaveContext(); } + const serialized = items.map( + (item) => ({ ...item.save(context) }) as Record, + ); + for (const itemData of serialized) { + if (itemData["name"] === "") delete itemData["name"]; + } + if (context.collectionFormat === "array") { - return items.map((item) => item.save(context)); + return serialized; + } + + const names = new Set(); + for (const itemData of serialized) { + const name = itemData["name"]; + if (typeof name !== "string" || name.length === 0 || names.has(name)) + return serialized; + names.add(name); } // Object format: use name as key const result: Record = {}; - for (const item of items) { - const itemData = item.save(context) as Record; - const name = itemData["name"] as string | undefined; + for (let index = 0; index < items.length; index++) { + const item = items[index]; + const itemData = serialized[index]; + const name = itemData["name"] as string; delete itemData["name"]; - if (name) { - // Check if we can use shorthand (only primary property set) - const shorthand = (item.constructor as typeof Property) - .shorthandProperty; - if ( - context.useShorthand && - shorthand && - Object.keys(itemData).length === 1 && - shorthand in itemData - ) { - result[name] = itemData[shorthand]; - continue; - } - result[name] = itemData; - } else { - // No name, fall back to array format for this item - if (!result["_unnamed"]) { - result["_unnamed"] = []; - } - (result["_unnamed"] as unknown[]).push(itemData); + // Check if we can use shorthand (only primary property set) + const shorthand = (item.constructor as typeof Property).shorthandProperty; + if ( + context.useShorthand && + shorthand && + Object.keys(itemData).length === 1 && + shorthand in itemData + ) { + result[name] = itemData[shorthand]; + continue; } + result[name] = itemData; } return result; } @@ -243,20 +305,31 @@ export class Prompty { data: Record[] | unknown[], context?: LoadContext, ): Tool[] { + context ??= new LoadContext({ path: "tools" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Tool[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Tool.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, kind: v }); + result.push(Tool.load({ name: k, kind: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Tool.load(item as Record, context), + return data.map((item, index) => + Tool.load(item as Record, context.atIndex(index)), ); } @@ -268,36 +341,44 @@ export class Prompty { context = new SaveContext(); } + const serialized = items.map( + (item) => ({ ...item.save(context) }) as Record, + ); + for (const itemData of serialized) { + if (itemData["name"] === "") delete itemData["name"]; + } + if (context.collectionFormat === "array") { - return items.map((item) => item.save(context)); + return serialized; + } + + const names = new Set(); + for (const itemData of serialized) { + const name = itemData["name"]; + if (typeof name !== "string" || name.length === 0 || names.has(name)) + return serialized; + names.add(name); } // Object format: use name as key const result: Record = {}; - for (const item of items) { - const itemData = item.save(context) as Record; - const name = itemData["name"] as string | undefined; + for (let index = 0; index < items.length; index++) { + const item = items[index]; + const itemData = serialized[index]; + const name = itemData["name"] as string; delete itemData["name"]; - if (name) { - // Check if we can use shorthand (only primary property set) - const shorthand = (item.constructor as typeof Tool).shorthandProperty; - if ( - context.useShorthand && - shorthand && - Object.keys(itemData).length === 1 && - shorthand in itemData - ) { - result[name] = itemData[shorthand]; - continue; - } - result[name] = itemData; - } else { - // No name, fall back to array format for this item - if (!result["_unnamed"]) { - result["_unnamed"] = []; - } - (result["_unnamed"] as unknown[]).push(itemData); + // Check if we can use shorthand (only primary property set) + const shorthand = (item.constructor as typeof Tool).shorthandProperty; + if ( + context.useShorthand && + shorthand && + Object.keys(itemData).length === 1 && + shorthand in itemData + ) { + result[name] = itemData[shorthand]; + continue; } + result[name] = itemData; } return result; } diff --git a/runtime/typescript/packages/core/src/model/connection/authorization-code-flow.ts b/runtime/typescript/packages/core/src/model/connection/authorization-code-flow.ts index ccee60e28..9d349e8b7 100644 --- a/runtime/typescript/packages/core/src/model/connection/authorization-code-flow.ts +++ b/runtime/typescript/packages/core/src/model/connection/authorization-code-flow.ts @@ -21,6 +21,7 @@ export class AuthorizationCodeFlow { data: Record, context?: LoadContext, ): AuthorizationCodeFlow { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/connection/connection.ts b/runtime/typescript/packages/core/src/model/connection/connection.ts index cbe015cf8..da7b990e1 100644 --- a/runtime/typescript/packages/core/src/model/connection/connection.ts +++ b/runtime/typescript/packages/core/src/model/connection/connection.ts @@ -12,6 +12,23 @@ export abstract class Connection { kind: string = ""; authenticationMode?: AuthenticationMode | undefined; usageDescription?: string | undefined; + protected raw: Record = {}; + + protected static cloneRawValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.cloneRawValue(item)); + } + if (value !== null && typeof value === "object") { + const result: Record = {}; + for (const [key, item] of Object.entries( + value as Record, + )) { + result[key] = this.cloneRawValue(item); + } + return result; + } + return value; + } constructor(init?: Partial) { this.kind = init?.kind ?? ""; @@ -29,6 +46,7 @@ export abstract class Connection { data: Record, context?: LoadContext, ): Connection { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -66,7 +84,7 @@ export abstract class Connection { ): Connection { const discriminatorValue = data["kind"]; if (discriminatorValue !== undefined && discriminatorValue !== null) { - const discriminator = String(discriminatorValue).toLowerCase(); + const discriminator = String(discriminatorValue); switch (discriminator) { case "reference": return ReferenceConnection.load(data, context); @@ -81,12 +99,10 @@ export abstract class Connection { case "foundry": return FoundryConnection.load(data, context); default: - throw new Error( - `Unknown Connection discriminator value: ${discriminator}`, - ); + return UnknownConnection.load(data, context); } } - throw new Error("Missing Connection discriminator property: 'kind'"); + return UnknownConnection.load(data, context); } //#endregion @@ -99,7 +115,7 @@ export abstract class Connection { obj = context.processObject(obj) as this; } - const result: Record = {}; + const result = Connection.cloneRawValue(obj.raw) as Record; if (obj.kind !== undefined && obj.kind !== null) { result["kind"] = obj.kind; @@ -144,6 +160,27 @@ export abstract class Connection { //#endregion } +/** + * Carries a Connection whose discriminator value matches no known subtype. + * + * The unrecognized value stays on `kind` and every + * key the schema does not declare is preserved verbatim, so an unknown Connection + * survives a load/save round-trip unchanged. + */ +export class UnknownConnection extends Connection { + static load( + data: Record, + context?: LoadContext, + ): UnknownConnection { + const instance = new UnknownConnection(); + instance.raw = Connection.cloneRawValue(data) as Record; + delete instance.raw["kind"]; + delete instance.raw["authenticationMode"]; + delete instance.raw["usageDescription"]; + return instance; + } +} + export class ReferenceConnection extends Connection { static readonly shorthandProperty: string | undefined = undefined; @@ -166,6 +203,7 @@ export class ReferenceConnection extends Connection { data: Record, context?: LoadContext, ): ReferenceConnection { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -257,6 +295,7 @@ export class RemoteConnection extends Connection { data: Record, context?: LoadContext, ): RemoteConnection { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -348,6 +387,7 @@ export class ApiKeyConnection extends Connection { data: Record, context?: LoadContext, ): ApiKeyConnection { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -437,6 +477,7 @@ export class AnonymousConnection extends Connection { data: Record, context?: LoadContext, ): AnonymousConnection { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -510,7 +551,7 @@ export class OAuthConnection extends Connection { clientId: string = ""; clientSecret: string = ""; tokenUrl: string = ""; - scopes?: string[] = []; + scopes?: string[]; constructor(init?: Partial) { super(init); @@ -530,6 +571,7 @@ export class OAuthConnection extends Connection { data: Record, context?: LoadContext, ): OAuthConnection { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -645,6 +687,7 @@ export class FoundryConnection extends Connection { data: Record, context?: LoadContext, ): FoundryConnection { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/connection/device-authorization.ts b/runtime/typescript/packages/core/src/model/connection/device-authorization.ts index ecad32f1a..6a4d808e5 100644 --- a/runtime/typescript/packages/core/src/model/connection/device-authorization.ts +++ b/runtime/typescript/packages/core/src/model/connection/device-authorization.ts @@ -29,6 +29,7 @@ export class DeviceAuthorization { data: Record, context?: LoadContext, ): DeviceAuthorization { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/connection/o-auth-token.ts b/runtime/typescript/packages/core/src/model/connection/o-auth-token.ts index 5687d9dae..306b66c87 100644 --- a/runtime/typescript/packages/core/src/model/connection/o-auth-token.ts +++ b/runtime/typescript/packages/core/src/model/connection/o-auth-token.ts @@ -31,6 +31,7 @@ export class OAuthToken { data: Record, context?: LoadContext, ): OAuthToken { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/context.ts b/runtime/typescript/packages/core/src/model/context.ts index 8307e52be..a6d6a4704 100644 --- a/runtime/typescript/packages/core/src/model/context.ts +++ b/runtime/typescript/packages/core/src/model/context.ts @@ -11,6 +11,8 @@ import * as yaml from "yaml"; * post-processing output data after instantiation. */ export class LoadContext { + readonly path: string; + /** * Optional callback to transform input data before parsing. */ @@ -22,14 +24,37 @@ export class LoadContext { postProcess?: (result: unknown) => unknown; constructor(init?: Partial) { + this.path = init?.path ?? ""; if (init?.preProcess) { this.preProcess = init.preProcess; } + if (init?.postProcess) { this.postProcess = init.postProcess; } } + at(segment: string): LoadContext { + return new LoadContext({ + preProcess: this.preProcess, + postProcess: this.postProcess, + path: this.path ? `${this.path}.${segment}` : segment, + }); + } + + /** + * Descend into an array element. Rendered with bracket notation + * (`messages[3]`) so an index is never confused with a map key of the + * same name, which dot-joining would make ambiguous. + */ + atIndex(index: number): LoadContext { + return new LoadContext({ + preProcess: this.preProcess, + postProcess: this.postProcess, + path: `${this.path}[${index}]`, + }); + } + /** * Apply pre-processing to input data if a preProcess callback is set. * @param data - The raw input dictionary to process. diff --git a/runtime/typescript/packages/core/src/model/conversation/content-part.ts b/runtime/typescript/packages/core/src/model/conversation/content-part.ts index 273eeada7..8539ae19d 100644 --- a/runtime/typescript/packages/core/src/model/conversation/content-part.ts +++ b/runtime/typescript/packages/core/src/model/conversation/content-part.ts @@ -19,6 +19,7 @@ export abstract class ContentPart { data: Record, context?: LoadContext, ): ContentPart { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -42,7 +43,7 @@ export abstract class ContentPart { ): ContentPart { const discriminatorValue = data["kind"]; if (discriminatorValue !== undefined && discriminatorValue !== null) { - const discriminator = String(discriminatorValue).toLowerCase(); + const discriminator = String(discriminatorValue); switch (discriminator) { case "text": return TextPart.load(data, context); @@ -54,7 +55,7 @@ export abstract class ContentPart { return AudioPart.load(data, context); default: throw new Error( - `Unknown ContentPart discriminator value: ${discriminator}`, + `Unknown ContentPart discriminator field 'kind' value: ${discriminator}`, ); } } @@ -122,6 +123,7 @@ export class TextPart extends ContentPart { //#region Load Methods static load(data: Record, context?: LoadContext): TextPart { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -210,6 +212,7 @@ export class ImagePart extends ContentPart { //#region Load Methods static load(data: Record, context?: LoadContext): ImagePart { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -306,6 +309,7 @@ export class FilePart extends ContentPart { //#region Load Methods static load(data: Record, context?: LoadContext): FilePart { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -396,6 +400,7 @@ export class AudioPart extends ContentPart { //#region Load Methods static load(data: Record, context?: LoadContext): AudioPart { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/conversation/message.ts b/runtime/typescript/packages/core/src/model/conversation/message.ts index fc62988a7..9ae7b2a76 100644 --- a/runtime/typescript/packages/core/src/model/conversation/message.ts +++ b/runtime/typescript/packages/core/src/model/conversation/message.ts @@ -23,6 +23,7 @@ export class Message { //#region Load Methods static load(data: Record, context?: LoadContext): Message { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -33,7 +34,10 @@ export class Message { instance.role = String(data["role"]) as Role; } if (data["parts"] !== undefined && data["parts"] !== null) { - instance.parts = Message.loadParts(data["parts"] as unknown[], context); + instance.parts = Message.loadParts( + data["parts"] as unknown[], + context.at("parts"), + ); } if (data["metadata"] !== undefined && data["metadata"] !== null) { instance.metadata = data["metadata"] as Record; @@ -49,20 +53,31 @@ export class Message { data: Record[] | unknown[], context?: LoadContext, ): ContentPart[] { + context ??= new LoadContext({ path: "parts" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: ContentPart[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + ContentPart.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, kind: v }); + result.push(ContentPart.load({ name: k, kind: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - ContentPart.load(item as Record, context), + return data.map((item, index) => + ContentPart.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/conversation/thread-marker.ts b/runtime/typescript/packages/core/src/model/conversation/thread-marker.ts index 47d739d6d..e377a2912 100644 --- a/runtime/typescript/packages/core/src/model/conversation/thread-marker.ts +++ b/runtime/typescript/packages/core/src/model/conversation/thread-marker.ts @@ -21,6 +21,7 @@ export class ThreadMarker { data: Record, context?: LoadContext, ): ThreadMarker { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/conversation/tool-call.ts b/runtime/typescript/packages/core/src/model/conversation/tool-call.ts index c792f5a52..9c045a0ca 100644 --- a/runtime/typescript/packages/core/src/model/conversation/tool-call.ts +++ b/runtime/typescript/packages/core/src/model/conversation/tool-call.ts @@ -20,6 +20,7 @@ export class ToolCall { //#region Load Methods static load(data: Record, context?: LoadContext): ToolCall { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/conversation/tool-result.ts b/runtime/typescript/packages/core/src/model/conversation/tool-result.ts index 6d6bc63b8..69ac867e9 100644 --- a/runtime/typescript/packages/core/src/model/conversation/tool-result.ts +++ b/runtime/typescript/packages/core/src/model/conversation/tool-result.ts @@ -38,6 +38,7 @@ export class ToolResult { data: Record, context?: LoadContext, ): ToolResult { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -47,7 +48,7 @@ export class ToolResult { if (data["parts"] !== undefined && data["parts"] !== null) { instance.parts = ToolResult.loadParts( data["parts"] as unknown[], - context, + context.at("parts"), ); } if (data["status"] !== undefined && data["status"] !== null) { @@ -73,20 +74,31 @@ export class ToolResult { data: Record[] | unknown[], context?: LoadContext, ): ContentPart[] { + context ??= new LoadContext({ path: "parts" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: ContentPart[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + ContentPart.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, kind: v }); + result.push(ContentPart.load({ name: k, kind: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - ContentPart.load(item as Record, context), + return data.map((item, index) => + ContentPart.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/core/file-not-found-error.ts b/runtime/typescript/packages/core/src/model/core/file-not-found-error.ts index b2e91f106..b824cb738 100644 --- a/runtime/typescript/packages/core/src/model/core/file-not-found-error.ts +++ b/runtime/typescript/packages/core/src/model/core/file-not-found-error.ts @@ -21,6 +21,7 @@ export class FileNotFoundError { data: Record, context?: LoadContext, ): FileNotFoundError { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/core/invoker-error.ts b/runtime/typescript/packages/core/src/model/core/invoker-error.ts index b4880b8ba..f3cc6c622 100644 --- a/runtime/typescript/packages/core/src/model/core/invoker-error.ts +++ b/runtime/typescript/packages/core/src/model/core/invoker-error.ts @@ -23,6 +23,7 @@ export class InvokerError { data: Record, context?: LoadContext, ): InvokerError { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/core/property.ts b/runtime/typescript/packages/core/src/model/core/property.ts index 1bac10392..50f8709ad 100644 --- a/runtime/typescript/packages/core/src/model/core/property.ts +++ b/runtime/typescript/packages/core/src/model/core/property.ts @@ -15,6 +15,23 @@ export class Property { default?: unknown | undefined; example?: unknown | undefined; enumValues?: unknown[] = []; + protected raw: Record = {}; + + protected static cloneRawValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.cloneRawValue(item)); + } + if (value !== null && typeof value === "object") { + const result: Record = {}; + for (const [key, item] of Object.entries( + value as Record, + )) { + result[key] = this.cloneRawValue(item); + } + return result; + } + return value; + } constructor(init?: Partial) { this.name = init?.name ?? ""; @@ -34,14 +51,13 @@ export class Property { if (init?.example !== undefined) { this.example = init.example; } - if (init?.enumValues !== undefined) { - this.enumValues = init.enumValues; - } + this.enumValues = init?.enumValues ?? []; } //#region Load Methods static load(data: Record, context?: LoadContext): Property { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -113,6 +129,10 @@ export class Property { instance.enumValues = data["enumValues"] as unknown[]; } + if (instance.constructor === Property) { + instance.raw = Property.cloneRawValue(data) as Record; + } + if (context) { return context.processOutput(instance) as Property; } @@ -125,7 +145,7 @@ export class Property { ): Property { const discriminatorValue = data["kind"]; if (discriminatorValue !== undefined && discriminatorValue !== null) { - const discriminator = String(discriminatorValue).toLowerCase(); + const discriminator = String(discriminatorValue); switch (discriminator) { case "array": return ArrayProperty.load(data, context); @@ -150,7 +170,7 @@ export class Property { obj = context.processObject(obj) as this; } - const result: Record = {}; + const result = Property.cloneRawValue(obj.raw) as Record; if (obj.name !== undefined && obj.name !== null) { result["name"] = obj.name; @@ -211,7 +231,7 @@ export class ArrayProperty extends Property { static readonly shorthandProperty: string | undefined = undefined; kind: string = "array"; - items!: Property; + items?: Property | undefined; constructor(init?: Partial) { super(init); @@ -227,6 +247,7 @@ export class ArrayProperty extends Property { data: Record, context?: LoadContext, ): ArrayProperty { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -239,7 +260,7 @@ export class ArrayProperty extends Property { if (data["items"] !== undefined && data["items"] !== null) { instance.items = Property.load( data["items"] as Record, - context, + context.at("items"), ); } @@ -313,6 +334,7 @@ export class ObjectProperty extends Property { data: Record, context?: LoadContext, ): ObjectProperty { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -325,7 +347,7 @@ export class ObjectProperty extends Property { if (data["properties"] !== undefined && data["properties"] !== null) { instance.properties = ObjectProperty.loadProperties( data["properties"] as unknown[], - context, + context.at("properties"), ); } @@ -339,20 +361,43 @@ export class ObjectProperty extends Property { data: Record[] | unknown[], context?: LoadContext, ): Property[] { + context ??= new LoadContext({ path: "properties" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Property[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Property.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, kind: v }); + let shorthand: Record; + if (typeof v === "number" && Number.isInteger(v)) { + shorthand = { kind: "integer", default: v }; + } else if (typeof v === "number") { + shorthand = { kind: "float", default: v }; + } else if (typeof v === "string") { + shorthand = { kind: "string", default: v }; + } else if (typeof v === "boolean") { + shorthand = { kind: "boolean", default: v }; + } else { + shorthand = { default: v }; + } + result.push(Property.load({ name: k, ...shorthand }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Property.load(item as Record, context), + return data.map((item, index) => + Property.load(item as Record, context.atIndex(index)), ); } @@ -364,37 +409,44 @@ export class ObjectProperty extends Property { context = new SaveContext(); } + const serialized = items.map( + (item) => ({ ...item.save(context) }) as Record, + ); + for (const itemData of serialized) { + if (itemData["name"] === "") delete itemData["name"]; + } + if (context.collectionFormat === "array") { - return items.map((item) => item.save(context)); + return serialized; + } + + const names = new Set(); + for (const itemData of serialized) { + const name = itemData["name"]; + if (typeof name !== "string" || name.length === 0 || names.has(name)) + return serialized; + names.add(name); } // Object format: use name as key const result: Record = {}; - for (const item of items) { - const itemData = item.save(context) as Record; - const name = itemData["name"] as string | undefined; + for (let index = 0; index < items.length; index++) { + const item = items[index]; + const itemData = serialized[index]; + const name = itemData["name"] as string; delete itemData["name"]; - if (name) { - // Check if we can use shorthand (only primary property set) - const shorthand = (item.constructor as typeof Property) - .shorthandProperty; - if ( - context.useShorthand && - shorthand && - Object.keys(itemData).length === 1 && - shorthand in itemData - ) { - result[name] = itemData[shorthand]; - continue; - } - result[name] = itemData; - } else { - // No name, fall back to array format for this item - if (!result["_unnamed"]) { - result["_unnamed"] = []; - } - (result["_unnamed"] as unknown[]).push(itemData); + // Check if we can use shorthand (only primary property set) + const shorthand = (item.constructor as typeof Property).shorthandProperty; + if ( + context.useShorthand && + shorthand && + Object.keys(itemData).length === 1 && + shorthand in itemData + ) { + result[name] = itemData[shorthand]; + continue; } + result[name] = itemData; } return result; } @@ -452,8 +504,8 @@ export class UnionProperty extends Property { static readonly shorthandProperty: string | undefined = undefined; kind: string = "union"; - oneOf?: Property[] = []; - anyOf?: Property[] = []; + oneOf?: Property[]; + anyOf?: Property[]; constructor(init?: Partial) { super(init); @@ -472,6 +524,7 @@ export class UnionProperty extends Property { data: Record, context?: LoadContext, ): UnionProperty { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -484,13 +537,13 @@ export class UnionProperty extends Property { if (data["oneOf"] !== undefined && data["oneOf"] !== null) { instance.oneOf = UnionProperty.loadOneOf( data["oneOf"] as unknown[], - context, + context.at("oneOf"), ); } if (data["anyOf"] !== undefined && data["anyOf"] !== null) { instance.anyOf = UnionProperty.loadAnyOf( data["anyOf"] as unknown[], - context, + context.at("anyOf"), ); } @@ -504,20 +557,43 @@ export class UnionProperty extends Property { data: Record[] | unknown[], context?: LoadContext, ): Property[] { + context ??= new LoadContext({ path: "oneOf" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Property[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Property.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, kind: v }); + let shorthand: Record; + if (typeof v === "number" && Number.isInteger(v)) { + shorthand = { kind: "integer", default: v }; + } else if (typeof v === "number") { + shorthand = { kind: "float", default: v }; + } else if (typeof v === "string") { + shorthand = { kind: "string", default: v }; + } else if (typeof v === "boolean") { + shorthand = { kind: "boolean", default: v }; + } else { + shorthand = { default: v }; + } + result.push(Property.load({ name: k, ...shorthand }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Property.load(item as Record, context), + return data.map((item, index) => + Property.load(item as Record, context.atIndex(index)), ); } @@ -537,20 +613,43 @@ export class UnionProperty extends Property { data: Record[] | unknown[], context?: LoadContext, ): Property[] { + context ??= new LoadContext({ path: "anyOf" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Property[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Property.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, kind: v }); + let shorthand: Record; + if (typeof v === "number" && Number.isInteger(v)) { + shorthand = { kind: "integer", default: v }; + } else if (typeof v === "number") { + shorthand = { kind: "float", default: v }; + } else if (typeof v === "string") { + shorthand = { kind: "string", default: v }; + } else if (typeof v === "boolean") { + shorthand = { kind: "boolean", default: v }; + } else { + shorthand = { default: v }; + } + result.push(Property.load({ name: k, ...shorthand }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Property.load(item as Record, context), + return data.map((item, index) => + Property.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/core/validation-error.ts b/runtime/typescript/packages/core/src/model/core/validation-error.ts index 6717a801a..79fb444f1 100644 --- a/runtime/typescript/packages/core/src/model/core/validation-error.ts +++ b/runtime/typescript/packages/core/src/model/core/validation-error.ts @@ -23,6 +23,7 @@ export class ValidationError { data: Record, context?: LoadContext, ): ValidationError { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/core/validation-result.ts b/runtime/typescript/packages/core/src/model/core/validation-result.ts index 757c4d862..cadbbb804 100644 --- a/runtime/typescript/packages/core/src/model/core/validation-result.ts +++ b/runtime/typescript/packages/core/src/model/core/validation-result.ts @@ -22,6 +22,7 @@ export class ValidationResult { data: Record, context?: LoadContext, ): ValidationResult { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -34,7 +35,7 @@ export class ValidationResult { if (data["errors"] !== undefined && data["errors"] !== null) { instance.errors = ValidationResult.loadErrors( data["errors"] as unknown[], - context, + context.at("errors"), ); } @@ -48,20 +49,36 @@ export class ValidationResult { data: Record[] | unknown[], context?: LoadContext, ): ValidationError[] { + context ??= new LoadContext({ path: "errors" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: ValidationError[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + ValidationError.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, message: v }); + result.push( + ValidationError.load({ name: k, message: v }, context.at(k)), + ); } } - data = result; + return result; } - return data.map((item) => - ValidationError.load(item as Record, context), + return data.map((item, index) => + ValidationError.load( + item as Record, + context.atIndex(index), + ), ); } diff --git a/runtime/typescript/packages/core/src/model/events/checkpoint.ts b/runtime/typescript/packages/core/src/model/events/checkpoint.ts index fbf803755..bd5399b82 100644 --- a/runtime/typescript/packages/core/src/model/events/checkpoint.ts +++ b/runtime/typescript/packages/core/src/model/events/checkpoint.ts @@ -60,6 +60,7 @@ export class Checkpoint { data: Record, context?: LoadContext, ): Checkpoint { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -102,7 +103,7 @@ export class Checkpoint { if (data["redaction"] !== undefined && data["redaction"] !== null) { instance.redaction = RedactionMetadata.load( data["redaction"] as Record, - context, + context.at("redaction"), ); } diff --git a/runtime/typescript/packages/core/src/model/events/compaction-complete-payload.ts b/runtime/typescript/packages/core/src/model/events/compaction-complete-payload.ts index 0eca9fa32..acc705cc5 100644 --- a/runtime/typescript/packages/core/src/model/events/compaction-complete-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/compaction-complete-payload.ts @@ -25,6 +25,7 @@ export class CompactionCompletePayload { data: Record, context?: LoadContext, ): CompactionCompletePayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/compaction-failed-payload.ts b/runtime/typescript/packages/core/src/model/events/compaction-failed-payload.ts index fa6091ec9..331d6ce39 100644 --- a/runtime/typescript/packages/core/src/model/events/compaction-failed-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/compaction-failed-payload.ts @@ -19,6 +19,7 @@ export class CompactionFailedPayload { data: Record, context?: LoadContext, ): CompactionFailedPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/compaction-start-payload.ts b/runtime/typescript/packages/core/src/model/events/compaction-start-payload.ts index 136dfbeb0..9252e62e9 100644 --- a/runtime/typescript/packages/core/src/model/events/compaction-start-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/compaction-start-payload.ts @@ -19,6 +19,7 @@ export class CompactionStartPayload { data: Record, context?: LoadContext, ): CompactionStartPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/done-event-payload.ts b/runtime/typescript/packages/core/src/model/events/done-event-payload.ts index 4e3f3dec9..5e9fc5c7b 100644 --- a/runtime/typescript/packages/core/src/model/events/done-event-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/done-event-payload.ts @@ -22,6 +22,7 @@ export class DoneEventPayload { data: Record, context?: LoadContext, ): DoneEventPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -34,7 +35,7 @@ export class DoneEventPayload { if (data["messages"] !== undefined && data["messages"] !== null) { instance.messages = DoneEventPayload.loadMessages( data["messages"] as unknown[], - context, + context.at("messages"), ); } @@ -48,20 +49,31 @@ export class DoneEventPayload { data: Record[] | unknown[], context?: LoadContext, ): Message[] { + context ??= new LoadContext({ path: "messages" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Message[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Message.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, role: v }); + result.push(Message.load({ name: k, role: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Message.load(item as Record, context), + return data.map((item, index) => + Message.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/events/error-event-payload.ts b/runtime/typescript/packages/core/src/model/events/error-event-payload.ts index 65b73039c..7ea4945b4 100644 --- a/runtime/typescript/packages/core/src/model/events/error-event-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/error-event-payload.ts @@ -27,6 +27,7 @@ export class ErrorEventPayload { data: Record, context?: LoadContext, ): ErrorEventPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/harness-context.ts b/runtime/typescript/packages/core/src/model/events/harness-context.ts index 6bb6cb981..4da327454 100644 --- a/runtime/typescript/packages/core/src/model/events/harness-context.ts +++ b/runtime/typescript/packages/core/src/model/events/harness-context.ts @@ -29,6 +29,7 @@ export class HarnessContext { data: Record, context?: LoadContext, ): HarnessContext { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/hook-end-payload.ts b/runtime/typescript/packages/core/src/model/events/hook-end-payload.ts index 5091b5339..1f34b149a 100644 --- a/runtime/typescript/packages/core/src/model/events/hook-end-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/hook-end-payload.ts @@ -46,6 +46,7 @@ export class HookEndPayload { data: Record, context?: LoadContext, ): HookEndPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -79,7 +80,7 @@ export class HookEndPayload { if (data["redaction"] !== undefined && data["redaction"] !== null) { instance.redaction = RedactionMetadata.load( data["redaction"] as Record, - context, + context.at("redaction"), ); } diff --git a/runtime/typescript/packages/core/src/model/events/hook-start-payload.ts b/runtime/typescript/packages/core/src/model/events/hook-start-payload.ts index e5cc1118e..f10d132ef 100644 --- a/runtime/typescript/packages/core/src/model/events/hook-start-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/hook-start-payload.ts @@ -36,6 +36,7 @@ export class HookStartPayload { data: Record, context?: LoadContext, ): HookStartPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -60,7 +61,7 @@ export class HookStartPayload { if (data["redaction"] !== undefined && data["redaction"] !== null) { instance.redaction = RedactionMetadata.load( data["redaction"] as Record, - context, + context.at("redaction"), ); } diff --git a/runtime/typescript/packages/core/src/model/events/host-tool-request.ts b/runtime/typescript/packages/core/src/model/events/host-tool-request.ts index 16edbef20..7fbfca612 100644 --- a/runtime/typescript/packages/core/src/model/events/host-tool-request.ts +++ b/runtime/typescript/packages/core/src/model/events/host-tool-request.ts @@ -35,6 +35,7 @@ export class HostToolRequest { data: Record, context?: LoadContext, ): HostToolRequest { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/host-tool-result.ts b/runtime/typescript/packages/core/src/model/events/host-tool-result.ts index 1a5628e46..01cd5f96f 100644 --- a/runtime/typescript/packages/core/src/model/events/host-tool-result.ts +++ b/runtime/typescript/packages/core/src/model/events/host-tool-result.ts @@ -49,6 +49,7 @@ export class HostToolResult { data: Record, context?: LoadContext, ): HostToolResult { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/llm-complete-payload.ts b/runtime/typescript/packages/core/src/model/events/llm-complete-payload.ts index 9aa1d6b32..7c96708e8 100644 --- a/runtime/typescript/packages/core/src/model/events/llm-complete-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/llm-complete-payload.ts @@ -34,6 +34,7 @@ export class LlmCompletePayload { data: Record, context?: LoadContext, ): LlmCompletePayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -52,7 +53,7 @@ export class LlmCompletePayload { if (data["usage"] !== undefined && data["usage"] !== null) { instance.usage = TokenUsage.load( data["usage"] as Record, - context, + context.at("usage"), ); } if (data["durationMs"] !== undefined && data["durationMs"] !== null) { diff --git a/runtime/typescript/packages/core/src/model/events/llm-start-payload.ts b/runtime/typescript/packages/core/src/model/events/llm-start-payload.ts index 5262733ff..74cb21f7b 100644 --- a/runtime/typescript/packages/core/src/model/events/llm-start-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/llm-start-payload.ts @@ -33,6 +33,7 @@ export class LlmStartPayload { data: Record, context?: LoadContext, ): LlmStartPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/messages-updated-payload.ts b/runtime/typescript/packages/core/src/model/events/messages-updated-payload.ts index b014fb0ef..8e947801e 100644 --- a/runtime/typescript/packages/core/src/model/events/messages-updated-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/messages-updated-payload.ts @@ -8,9 +8,9 @@ import { Message } from "../conversation/message"; export class MessagesUpdatedPayload { static readonly shorthandProperty: string | undefined = undefined; - messages?: Message[] = []; + messages?: Message[]; reason?: string | undefined; - appended?: Message[] = []; + appended?: Message[]; removed?: number | undefined; constructor(init?: Partial) { @@ -34,6 +34,7 @@ export class MessagesUpdatedPayload { data: Record, context?: LoadContext, ): MessagesUpdatedPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -43,7 +44,7 @@ export class MessagesUpdatedPayload { if (data["messages"] !== undefined && data["messages"] !== null) { instance.messages = MessagesUpdatedPayload.loadMessages( data["messages"] as unknown[], - context, + context.at("messages"), ); } if (data["reason"] !== undefined && data["reason"] !== null) { @@ -52,7 +53,7 @@ export class MessagesUpdatedPayload { if (data["appended"] !== undefined && data["appended"] !== null) { instance.appended = MessagesUpdatedPayload.loadAppended( data["appended"] as unknown[], - context, + context.at("appended"), ); } if (data["removed"] !== undefined && data["removed"] !== null) { @@ -69,20 +70,31 @@ export class MessagesUpdatedPayload { data: Record[] | unknown[], context?: LoadContext, ): Message[] { + context ??= new LoadContext({ path: "messages" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Message[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Message.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, role: v }); + result.push(Message.load({ name: k, role: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Message.load(item as Record, context), + return data.map((item, index) => + Message.load(item as Record, context.atIndex(index)), ); } @@ -102,20 +114,31 @@ export class MessagesUpdatedPayload { data: Record[] | unknown[], context?: LoadContext, ): Message[] { + context ??= new LoadContext({ path: "appended" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Message[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Message.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, role: v }); + result.push(Message.load({ name: k, role: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Message.load(item as Record, context), + return data.map((item, index) => + Message.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/events/permission-completed-payload.ts b/runtime/typescript/packages/core/src/model/events/permission-completed-payload.ts index 63a3dde13..b12464d8c 100644 --- a/runtime/typescript/packages/core/src/model/events/permission-completed-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/permission-completed-payload.ts @@ -42,6 +42,7 @@ export class PermissionCompletedPayload { data: Record, context?: LoadContext, ): PermissionCompletedPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -69,7 +70,7 @@ export class PermissionCompletedPayload { if (data["redaction"] !== undefined && data["redaction"] !== null) { instance.redaction = RedactionMetadata.load( data["redaction"] as Record, - context, + context.at("redaction"), ); } diff --git a/runtime/typescript/packages/core/src/model/events/permission-decision.ts b/runtime/typescript/packages/core/src/model/events/permission-decision.ts index d818a600e..097224773 100644 --- a/runtime/typescript/packages/core/src/model/events/permission-decision.ts +++ b/runtime/typescript/packages/core/src/model/events/permission-decision.ts @@ -37,6 +37,7 @@ export class PermissionDecision { data: Record, context?: LoadContext, ): PermissionDecision { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/permission-request.ts b/runtime/typescript/packages/core/src/model/events/permission-request.ts index e3e25e7c8..bbeffce7e 100644 --- a/runtime/typescript/packages/core/src/model/events/permission-request.ts +++ b/runtime/typescript/packages/core/src/model/events/permission-request.ts @@ -43,6 +43,7 @@ export class PermissionRequest { data: Record, context?: LoadContext, ): PermissionRequest { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/permission-requested-payload.ts b/runtime/typescript/packages/core/src/model/events/permission-requested-payload.ts index 5d4d3b8fe..f993d3870 100644 --- a/runtime/typescript/packages/core/src/model/events/permission-requested-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/permission-requested-payload.ts @@ -48,6 +48,7 @@ export class PermissionRequestedPayload { data: Record, context?: LoadContext, ): PermissionRequestedPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -78,7 +79,7 @@ export class PermissionRequestedPayload { if (data["redaction"] !== undefined && data["redaction"] !== null) { instance.redaction = RedactionMetadata.load( data["redaction"] as Record, - context, + context.at("redaction"), ); } diff --git a/runtime/typescript/packages/core/src/model/events/redacted-field.ts b/runtime/typescript/packages/core/src/model/events/redacted-field.ts index d9636cba7..9ed198093 100644 --- a/runtime/typescript/packages/core/src/model/events/redacted-field.ts +++ b/runtime/typescript/packages/core/src/model/events/redacted-field.ts @@ -32,6 +32,7 @@ export class RedactedField { data: Record, context?: LoadContext, ): RedactedField { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/redaction-metadata.ts b/runtime/typescript/packages/core/src/model/events/redaction-metadata.ts index c08fbb880..a9629ee8f 100644 --- a/runtime/typescript/packages/core/src/model/events/redaction-metadata.ts +++ b/runtime/typescript/packages/core/src/model/events/redaction-metadata.ts @@ -9,7 +9,7 @@ export class RedactionMetadata { static readonly shorthandProperty: string | undefined = undefined; sanitized?: boolean | undefined; - fields?: RedactedField[] = []; + fields?: RedactedField[]; policy?: string | undefined; constructor(init?: Partial) { @@ -30,6 +30,7 @@ export class RedactionMetadata { data: Record, context?: LoadContext, ): RedactionMetadata { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -42,7 +43,7 @@ export class RedactionMetadata { if (data["fields"] !== undefined && data["fields"] !== null) { instance.fields = RedactionMetadata.loadFields( data["fields"] as unknown[], - context, + context.at("fields"), ); } if (data["policy"] !== undefined && data["policy"] !== null) { @@ -59,20 +60,34 @@ export class RedactionMetadata { data: Record[] | unknown[], context?: LoadContext, ): RedactedField[] { + context ??= new LoadContext({ path: "fields" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: RedactedField[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + RedactedField.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, path: v }); + result.push(RedactedField.load({ name: k, path: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - RedactedField.load(item as Record, context), + return data.map((item, index) => + RedactedField.load( + item as Record, + context.atIndex(index), + ), ); } diff --git a/runtime/typescript/packages/core/src/model/events/retry-payload.ts b/runtime/typescript/packages/core/src/model/events/retry-payload.ts index 464703112..16e564344 100644 --- a/runtime/typescript/packages/core/src/model/events/retry-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/retry-payload.ts @@ -33,6 +33,7 @@ export class RetryPayload { data: Record, context?: LoadContext, ): RetryPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/session-end-payload.ts b/runtime/typescript/packages/core/src/model/events/session-end-payload.ts index 16a29212b..0ba10738e 100644 --- a/runtime/typescript/packages/core/src/model/events/session-end-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/session-end-payload.ts @@ -39,6 +39,7 @@ export class SessionEndPayload { data: Record, context?: LoadContext, ): SessionEndPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/session-event.ts b/runtime/typescript/packages/core/src/model/events/session-event.ts index bec2b5fb5..96b250284 100644 --- a/runtime/typescript/packages/core/src/model/events/session-event.ts +++ b/runtime/typescript/packages/core/src/model/events/session-event.ts @@ -55,6 +55,7 @@ export class SessionEvent { data: Record, context?: LoadContext, ): SessionEvent { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -88,7 +89,7 @@ export class SessionEvent { if (data["redaction"] !== undefined && data["redaction"] !== null) { instance.redaction = RedactionMetadata.load( data["redaction"] as Record, - context, + context.at("redaction"), ); } diff --git a/runtime/typescript/packages/core/src/model/events/session-file-ref.ts b/runtime/typescript/packages/core/src/model/events/session-file-ref.ts index 558e3507c..4f49bba9d 100644 --- a/runtime/typescript/packages/core/src/model/events/session-file-ref.ts +++ b/runtime/typescript/packages/core/src/model/events/session-file-ref.ts @@ -35,6 +35,7 @@ export class SessionFileRef { data: Record, context?: LoadContext, ): SessionFileRef { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/session-ref.ts b/runtime/typescript/packages/core/src/model/events/session-ref.ts index 5c30c5c53..0f93ef358 100644 --- a/runtime/typescript/packages/core/src/model/events/session-ref.ts +++ b/runtime/typescript/packages/core/src/model/events/session-ref.ts @@ -33,6 +33,7 @@ export class SessionRef { data: Record, context?: LoadContext, ): SessionRef { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/session-start-payload.ts b/runtime/typescript/packages/core/src/model/events/session-start-payload.ts index dbba60b7f..f87c430d5 100644 --- a/runtime/typescript/packages/core/src/model/events/session-start-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/session-start-payload.ts @@ -52,6 +52,7 @@ export class SessionStartPayload { data: Record, context?: LoadContext, ): SessionStartPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -91,7 +92,7 @@ export class SessionStartPayload { if (data["context"] !== undefined && data["context"] !== null) { instance.context = HarnessContext.load( data["context"] as Record, - context, + context.at("context"), ); } diff --git a/runtime/typescript/packages/core/src/model/events/session-summary.ts b/runtime/typescript/packages/core/src/model/events/session-summary.ts index 539050b40..ae82ee814 100644 --- a/runtime/typescript/packages/core/src/model/events/session-summary.ts +++ b/runtime/typescript/packages/core/src/model/events/session-summary.ts @@ -46,6 +46,7 @@ export class SessionSummary { data: Record, context?: LoadContext, ): SessionSummary { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -67,7 +68,7 @@ export class SessionSummary { if (data["usage"] !== undefined && data["usage"] !== null) { instance.usage = TokenUsage.load( data["usage"] as Record, - context, + context.at("usage"), ); } if (data["durationMs"] !== undefined && data["durationMs"] !== null) { diff --git a/runtime/typescript/packages/core/src/model/events/session-trace.ts b/runtime/typescript/packages/core/src/model/events/session-trace.ts index f2067a861..fb4c09fe8 100644 --- a/runtime/typescript/packages/core/src/model/events/session-trace.ts +++ b/runtime/typescript/packages/core/src/model/events/session-trace.ts @@ -19,11 +19,11 @@ export class SessionTrace { promptyVersion?: string | undefined; sessionId?: string | undefined; events: SessionEvent[] = []; - turns?: TurnTrace[] = []; - checkpoints?: Checkpoint[] = []; - trajectory?: TrajectoryEvent[] = []; - files?: SessionFileRef[] = []; - refs?: SessionRef[] = []; + turns?: TurnTrace[]; + checkpoints?: Checkpoint[]; + trajectory?: TrajectoryEvent[]; + files?: SessionFileRef[]; + refs?: SessionRef[]; summary?: SessionSummary | undefined; constructor(init?: Partial) { @@ -64,6 +64,7 @@ export class SessionTrace { data: Record, context?: LoadContext, ): SessionTrace { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -88,40 +89,43 @@ export class SessionTrace { if (data["events"] !== undefined && data["events"] !== null) { instance.events = SessionTrace.loadEvents( data["events"] as unknown[], - context, + context.at("events"), ); } if (data["turns"] !== undefined && data["turns"] !== null) { instance.turns = SessionTrace.loadTurns( data["turns"] as unknown[], - context, + context.at("turns"), ); } if (data["checkpoints"] !== undefined && data["checkpoints"] !== null) { instance.checkpoints = SessionTrace.loadCheckpoints( data["checkpoints"] as unknown[], - context, + context.at("checkpoints"), ); } if (data["trajectory"] !== undefined && data["trajectory"] !== null) { instance.trajectory = SessionTrace.loadTrajectory( data["trajectory"] as unknown[], - context, + context.at("trajectory"), ); } if (data["files"] !== undefined && data["files"] !== null) { instance.files = SessionTrace.loadFiles( data["files"] as unknown[], - context, + context.at("files"), ); } if (data["refs"] !== undefined && data["refs"] !== null) { - instance.refs = SessionTrace.loadRefs(data["refs"] as unknown[], context); + instance.refs = SessionTrace.loadRefs( + data["refs"] as unknown[], + context.at("refs"), + ); } if (data["summary"] !== undefined && data["summary"] !== null) { instance.summary = SessionSummary.load( data["summary"] as Record, - context, + context.at("summary"), ); } @@ -135,20 +139,34 @@ export class SessionTrace { data: Record[] | unknown[], context?: LoadContext, ): SessionEvent[] { + context ??= new LoadContext({ path: "events" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: SessionEvent[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + SessionEvent.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, id: v }); + result.push(SessionEvent.load({ name: k, id: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - SessionEvent.load(item as Record, context), + return data.map((item, index) => + SessionEvent.load( + item as Record, + context.atIndex(index), + ), ); } @@ -168,20 +186,31 @@ export class SessionTrace { data: Record[] | unknown[], context?: LoadContext, ): TurnTrace[] { + context ??= new LoadContext({ path: "turns" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: TurnTrace[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + TurnTrace.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, version: v }); + result.push(TurnTrace.load({ name: k, version: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - TurnTrace.load(item as Record, context), + return data.map((item, index) => + TurnTrace.load(item as Record, context.atIndex(index)), ); } @@ -201,20 +230,31 @@ export class SessionTrace { data: Record[] | unknown[], context?: LoadContext, ): Checkpoint[] { + context ??= new LoadContext({ path: "checkpoints" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Checkpoint[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Checkpoint.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, id: v }); + result.push(Checkpoint.load({ name: k, id: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Checkpoint.load(item as Record, context), + return data.map((item, index) => + Checkpoint.load(item as Record, context.atIndex(index)), ); } @@ -234,20 +274,34 @@ export class SessionTrace { data: Record[] | unknown[], context?: LoadContext, ): TrajectoryEvent[] { + context ??= new LoadContext({ path: "trajectory" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: TrajectoryEvent[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + TrajectoryEvent.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, id: v }); + result.push(TrajectoryEvent.load({ name: k, id: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - TrajectoryEvent.load(item as Record, context), + return data.map((item, index) => + TrajectoryEvent.load( + item as Record, + context.atIndex(index), + ), ); } @@ -267,20 +321,36 @@ export class SessionTrace { data: Record[] | unknown[], context?: LoadContext, ): SessionFileRef[] { + context ??= new LoadContext({ path: "files" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: SessionFileRef[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + SessionFileRef.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, sessionId: v }); + result.push( + SessionFileRef.load({ name: k, sessionId: v }, context.at(k)), + ); } } - data = result; + return result; } - return data.map((item) => - SessionFileRef.load(item as Record, context), + return data.map((item, index) => + SessionFileRef.load( + item as Record, + context.atIndex(index), + ), ); } @@ -300,20 +370,33 @@ export class SessionTrace { data: Record[] | unknown[], context?: LoadContext, ): SessionRef[] { + context ??= new LoadContext({ path: "refs" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: SessionRef[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + SessionRef.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, sessionId: v }); + result.push( + SessionRef.load({ name: k, sessionId: v }, context.at(k)), + ); } } - data = result; + return result; } - return data.map((item) => - SessionRef.load(item as Record, context), + return data.map((item, index) => + SessionRef.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/events/session-warning-payload.ts b/runtime/typescript/packages/core/src/model/events/session-warning-payload.ts index 5521086d7..77ebfb0a9 100644 --- a/runtime/typescript/packages/core/src/model/events/session-warning-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/session-warning-payload.ts @@ -25,6 +25,7 @@ export class SessionWarningPayload { data: Record, context?: LoadContext, ): SessionWarningPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/status-event-payload.ts b/runtime/typescript/packages/core/src/model/events/status-event-payload.ts index 8752ecdf4..ffab769b2 100644 --- a/runtime/typescript/packages/core/src/model/events/status-event-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/status-event-payload.ts @@ -19,6 +19,7 @@ export class StatusEventPayload { data: Record, context?: LoadContext, ): StatusEventPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/stream-chunk.ts b/runtime/typescript/packages/core/src/model/events/stream-chunk.ts index 48feda688..19c2d4dd0 100644 --- a/runtime/typescript/packages/core/src/model/events/stream-chunk.ts +++ b/runtime/typescript/packages/core/src/model/events/stream-chunk.ts @@ -21,6 +21,7 @@ export abstract class StreamChunk { data: Record, context?: LoadContext, ): StreamChunk { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -44,7 +45,7 @@ export abstract class StreamChunk { ): StreamChunk { const discriminatorValue = data["kind"]; if (discriminatorValue !== undefined && discriminatorValue !== null) { - const discriminator = String(discriminatorValue).toLowerCase(); + const discriminator = String(discriminatorValue); switch (discriminator) { case "text": return TextChunk.load(data, context); @@ -58,7 +59,7 @@ export abstract class StreamChunk { return ErrorChunk.load(data, context); default: throw new Error( - `Unknown StreamChunk discriminator value: ${discriminator}`, + `Unknown StreamChunk discriminator field 'kind' value: ${discriminator}`, ); } } @@ -126,6 +127,7 @@ export class TextChunk extends StreamChunk { //#region Load Methods static load(data: Record, context?: LoadContext): TextChunk { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -209,6 +211,7 @@ export class ThinkingChunk extends StreamChunk { data: Record, context?: LoadContext, ): ThinkingChunk { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -291,10 +294,14 @@ export class ToolChunk extends StreamChunk { //#region Load Methods static load(data: Record, context?: LoadContext): ToolChunk { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["toolCall"] === undefined || data["toolCall"] === null) { + throw new Error(`${context.at("toolCall").path}: missing required field`); + } const instance = new ToolChunk(); if (data["kind"] !== undefined && data["kind"] !== null) { @@ -303,7 +310,7 @@ export class ToolChunk extends StreamChunk { if (data["toolCall"] !== undefined && data["toolCall"] !== null) { instance.toolCall = ToolCall.load( data["toolCall"] as Record, - context, + context.at("toolCall"), ); } @@ -379,10 +386,14 @@ export class UsageChunk extends StreamChunk { data: Record, context?: LoadContext, ): UsageChunk { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["usage"] === undefined || data["usage"] === null) { + throw new Error(`${context.at("usage").path}: missing required field`); + } const instance = new UsageChunk(); if (data["kind"] !== undefined && data["kind"] !== null) { @@ -391,7 +402,7 @@ export class UsageChunk extends StreamChunk { if (data["usage"] !== undefined && data["usage"] !== null) { instance.usage = InvocationUsage.load( data["usage"] as Record, - context, + context.at("usage"), ); } @@ -465,6 +476,7 @@ export class ErrorChunk extends StreamChunk { data: Record, context?: LoadContext, ): ErrorChunk { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/thinking-event-payload.ts b/runtime/typescript/packages/core/src/model/events/thinking-event-payload.ts index 4e77e6f09..ea951d374 100644 --- a/runtime/typescript/packages/core/src/model/events/thinking-event-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/thinking-event-payload.ts @@ -19,6 +19,7 @@ export class ThinkingEventPayload { data: Record, context?: LoadContext, ): ThinkingEventPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/token-event-payload.ts b/runtime/typescript/packages/core/src/model/events/token-event-payload.ts index b334e702f..09460e1a9 100644 --- a/runtime/typescript/packages/core/src/model/events/token-event-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/token-event-payload.ts @@ -19,6 +19,7 @@ export class TokenEventPayload { data: Record, context?: LoadContext, ): TokenEventPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/tool-call-complete-payload.ts b/runtime/typescript/packages/core/src/model/events/tool-call-complete-payload.ts index 18e2a870e..8db5470b4 100644 --- a/runtime/typescript/packages/core/src/model/events/tool-call-complete-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/tool-call-complete-payload.ts @@ -38,6 +38,7 @@ export class ToolCallCompletePayload { data: Record, context?: LoadContext, ): ToolCallCompletePayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -56,7 +57,7 @@ export class ToolCallCompletePayload { if (data["result"] !== undefined && data["result"] !== null) { instance.result = ToolResult.load( data["result"] as Record, - context, + context.at("result"), ); } if (data["durationMs"] !== undefined && data["durationMs"] !== null) { diff --git a/runtime/typescript/packages/core/src/model/events/tool-call-start-payload.ts b/runtime/typescript/packages/core/src/model/events/tool-call-start-payload.ts index 23ae073e4..7a9c4df9c 100644 --- a/runtime/typescript/packages/core/src/model/events/tool-call-start-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/tool-call-start-payload.ts @@ -25,6 +25,7 @@ export class ToolCallStartPayload { data: Record, context?: LoadContext, ): ToolCallStartPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/tool-execution-complete-payload.ts b/runtime/typescript/packages/core/src/model/events/tool-execution-complete-payload.ts index 07e6edca0..bde5c37dc 100644 --- a/runtime/typescript/packages/core/src/model/events/tool-execution-complete-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/tool-execution-complete-payload.ts @@ -54,6 +54,7 @@ export class ToolExecutionCompletePayload { data: Record, context?: LoadContext, ): ToolExecutionCompletePayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -90,7 +91,7 @@ export class ToolExecutionCompletePayload { if (data["redaction"] !== undefined && data["redaction"] !== null) { instance.redaction = RedactionMetadata.load( data["redaction"] as Record, - context, + context.at("redaction"), ); } diff --git a/runtime/typescript/packages/core/src/model/events/tool-execution-start-payload.ts b/runtime/typescript/packages/core/src/model/events/tool-execution-start-payload.ts index a3641573a..13e6aa7dc 100644 --- a/runtime/typescript/packages/core/src/model/events/tool-execution-start-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/tool-execution-start-payload.ts @@ -40,6 +40,7 @@ export class ToolExecutionStartPayload { data: Record, context?: LoadContext, ): ToolExecutionStartPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -67,7 +68,7 @@ export class ToolExecutionStartPayload { if (data["redaction"] !== undefined && data["redaction"] !== null) { instance.redaction = RedactionMetadata.load( data["redaction"] as Record, - context, + context.at("redaction"), ); } diff --git a/runtime/typescript/packages/core/src/model/events/tool-result-payload.ts b/runtime/typescript/packages/core/src/model/events/tool-result-payload.ts index 450d88946..526ea4ec6 100644 --- a/runtime/typescript/packages/core/src/model/events/tool-result-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/tool-result-payload.ts @@ -24,10 +24,14 @@ export class ToolResultPayload { data: Record, context?: LoadContext, ): ToolResultPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["result"] === undefined || data["result"] === null) { + throw new Error(`${context.at("result").path}: missing required field`); + } const instance = new ToolResultPayload(); if (data["name"] !== undefined && data["name"] !== null) { @@ -36,7 +40,7 @@ export class ToolResultPayload { if (data["result"] !== undefined && data["result"] !== null) { instance.result = ToolResult.load( data["result"] as Record, - context, + context.at("result"), ); } diff --git a/runtime/typescript/packages/core/src/model/events/trajectory-event.ts b/runtime/typescript/packages/core/src/model/events/trajectory-event.ts index f32a2a29c..01453550e 100644 --- a/runtime/typescript/packages/core/src/model/events/trajectory-event.ts +++ b/runtime/typescript/packages/core/src/model/events/trajectory-event.ts @@ -52,6 +52,7 @@ export class TrajectoryEvent { data: Record, context?: LoadContext, ): TrajectoryEvent { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -85,7 +86,7 @@ export class TrajectoryEvent { if (data["redaction"] !== undefined && data["redaction"] !== null) { instance.redaction = RedactionMetadata.load( data["redaction"] as Record, - context, + context.at("redaction"), ); } diff --git a/runtime/typescript/packages/core/src/model/events/turn-end-payload.ts b/runtime/typescript/packages/core/src/model/events/turn-end-payload.ts index 2d3266c0f..64851822e 100644 --- a/runtime/typescript/packages/core/src/model/events/turn-end-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/turn-end-payload.ts @@ -35,6 +35,7 @@ export class TurnEndPayload { data: Record, context?: LoadContext, ): TurnEndPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/turn-event.ts b/runtime/typescript/packages/core/src/model/events/turn-event.ts index 8a68d6d57..2dff52271 100644 --- a/runtime/typescript/packages/core/src/model/events/turn-event.ts +++ b/runtime/typescript/packages/core/src/model/events/turn-event.ts @@ -64,6 +64,7 @@ export class TurnEvent { //#region Load Methods static load(data: Record, context?: LoadContext): TurnEvent { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/turn-start-payload.ts b/runtime/typescript/packages/core/src/model/events/turn-start-payload.ts index f20942176..a3eac496c 100644 --- a/runtime/typescript/packages/core/src/model/events/turn-start-payload.ts +++ b/runtime/typescript/packages/core/src/model/events/turn-start-payload.ts @@ -29,6 +29,7 @@ export class TurnStartPayload { data: Record, context?: LoadContext, ): TurnStartPayload { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/events/turn-summary.ts b/runtime/typescript/packages/core/src/model/events/turn-summary.ts index 1dce7ee2d..40640d25b 100644 --- a/runtime/typescript/packages/core/src/model/events/turn-summary.ts +++ b/runtime/typescript/packages/core/src/model/events/turn-summary.ts @@ -44,6 +44,7 @@ export class TurnSummary { data: Record, context?: LoadContext, ): TurnSummary { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -71,7 +72,7 @@ export class TurnSummary { if (data["usage"] !== undefined && data["usage"] !== null) { instance.usage = TokenUsage.load( data["usage"] as Record, - context, + context.at("usage"), ); } if (data["durationMs"] !== undefined && data["durationMs"] !== null) { diff --git a/runtime/typescript/packages/core/src/model/events/turn-trace.ts b/runtime/typescript/packages/core/src/model/events/turn-trace.ts index 786f954cf..eccbfd6fb 100644 --- a/runtime/typescript/packages/core/src/model/events/turn-trace.ts +++ b/runtime/typescript/packages/core/src/model/events/turn-trace.ts @@ -32,6 +32,7 @@ export class TurnTrace { //#region Load Methods static load(data: Record, context?: LoadContext): TurnTrace { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -53,13 +54,13 @@ export class TurnTrace { if (data["events"] !== undefined && data["events"] !== null) { instance.events = TurnTrace.loadEvents( data["events"] as unknown[], - context, + context.at("events"), ); } if (data["summary"] !== undefined && data["summary"] !== null) { instance.summary = TurnSummary.load( data["summary"] as Record, - context, + context.at("summary"), ); } @@ -73,20 +74,31 @@ export class TurnTrace { data: Record[] | unknown[], context?: LoadContext, ): TurnEvent[] { + context ??= new LoadContext({ path: "events" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: TurnEvent[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + TurnEvent.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, id: v }); + result.push(TurnEvent.load({ name: k, id: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - TurnEvent.load(item as Record, context), + return data.map((item, index) => + TurnEvent.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/index.ts b/runtime/typescript/packages/core/src/model/index.ts index 90a5b1a69..d7ba1153e 100644 --- a/runtime/typescript/packages/core/src/model/index.ts +++ b/runtime/typescript/packages/core/src/model/index.ts @@ -123,6 +123,10 @@ export { EngineCheckpoint } from "./pipeline/engine-checkpoint"; export { ResumeContext } from "./pipeline/resume-context"; export { TurnCommit } from "./pipeline/turn-commit"; export { TurnEngineResult } from "./pipeline/turn-engine-result"; +export type { EnginePermissionPort } from "./pipeline/engine-permission-port"; +export type { EngineToolPort } from "./pipeline/engine-tool-port"; +export type { EngineDurabilityPort } from "./pipeline/engine-durability-port"; +export type { EnginePostCommitPort } from "./pipeline/engine-post-commit-port"; export { HostPolicyRequest } from "./pipeline/host-policy-request"; export { HostPolicyResult } from "./pipeline/host-policy-result"; export { FinalOutputPolicyRequest } from "./pipeline/final-output-policy-request"; diff --git a/runtime/typescript/packages/core/src/model/memory/memory-entry.ts b/runtime/typescript/packages/core/src/model/memory/memory-entry.ts index 71139743d..bb6797141 100644 --- a/runtime/typescript/packages/core/src/model/memory/memory-entry.ts +++ b/runtime/typescript/packages/core/src/model/memory/memory-entry.ts @@ -12,7 +12,7 @@ export class MemoryEntry { content: string = ""; category: MemoryCategory = "core"; createdAt?: string | undefined; - tags?: string[] = []; + tags?: string[]; constructor(init?: Partial) { this.content = init?.content ?? ""; @@ -31,6 +31,7 @@ export class MemoryEntry { data: Record, context?: LoadContext, ): MemoryEntry { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/memory/memory-store.ts b/runtime/typescript/packages/core/src/model/memory/memory-store.ts index 333a0e1ba..061031476 100644 --- a/runtime/typescript/packages/core/src/model/memory/memory-store.ts +++ b/runtime/typescript/packages/core/src/model/memory/memory-store.ts @@ -20,6 +20,7 @@ export class MemoryStore { data: Record, context?: LoadContext, ): MemoryStore { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -29,7 +30,7 @@ export class MemoryStore { if (data["entries"] !== undefined && data["entries"] !== null) { instance.entries = MemoryStore.loadEntries( data["entries"] as unknown[], - context, + context.at("entries"), ); } @@ -43,20 +44,31 @@ export class MemoryStore { data: Record[] | unknown[], context?: LoadContext, ): MemoryEntry[] { + context ??= new LoadContext({ path: "entries" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: MemoryEntry[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + MemoryEntry.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, content: v }); + result.push(MemoryEntry.load({ name: k, content: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - MemoryEntry.load(item as Record, context), + return data.map((item, index) => + MemoryEntry.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/model/ai-resource-info.ts b/runtime/typescript/packages/core/src/model/model/ai-resource-info.ts index 53161db44..d3c2d5cab 100644 --- a/runtime/typescript/packages/core/src/model/model/ai-resource-info.ts +++ b/runtime/typescript/packages/core/src/model/model/ai-resource-info.ts @@ -31,6 +31,7 @@ export class AiResourceInfo { data: Record, context?: LoadContext, ): AiResourceInfo { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/model/invocation-usage.ts b/runtime/typescript/packages/core/src/model/model/invocation-usage.ts index 618ec6fc2..4c314447a 100644 --- a/runtime/typescript/packages/core/src/model/model/invocation-usage.ts +++ b/runtime/typescript/packages/core/src/model/model/invocation-usage.ts @@ -23,6 +23,7 @@ export class InvocationUsage { data: Record, context?: LoadContext, ): InvocationUsage { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/model/model-info.ts b/runtime/typescript/packages/core/src/model/model/model-info.ts index f934b6549..57537e915 100644 --- a/runtime/typescript/packages/core/src/model/model/model-info.ts +++ b/runtime/typescript/packages/core/src/model/model/model-info.ts @@ -11,8 +11,8 @@ export class ModelInfo { displayName?: string | undefined; ownedBy?: string | undefined; contextWindow?: number | undefined; - inputModalities?: string[] = []; - outputModalities?: string[] = []; + inputModalities?: string[]; + outputModalities?: string[]; additionalProperties?: Record | undefined; constructor(init?: Partial) { @@ -40,6 +40,7 @@ export class ModelInfo { //#region Load Methods static load(data: Record, context?: LoadContext): ModelInfo { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/model/model-options.ts b/runtime/typescript/packages/core/src/model/model/model-options.ts index 806ed03a8..b82378202 100644 --- a/runtime/typescript/packages/core/src/model/model/model-options.ts +++ b/runtime/typescript/packages/core/src/model/model/model-options.ts @@ -14,7 +14,7 @@ export class ModelOptions { temperature?: number | undefined; topK?: number | undefined; topP?: number | undefined; - stopSequences?: string[] = []; + stopSequences?: string[]; allowMultipleToolCalls?: boolean | undefined; additionalProperties?: Record | undefined; @@ -57,6 +57,7 @@ export class ModelOptions { data: Record, context?: LoadContext, ): ModelOptions { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/model/model.ts b/runtime/typescript/packages/core/src/model/model/model.ts index 4d9bed76f..cb6464808 100644 --- a/runtime/typescript/packages/core/src/model/model/model.ts +++ b/runtime/typescript/packages/core/src/model/model/model.ts @@ -41,6 +41,7 @@ export class Model { //#region Load Methods static load(data: Record, context?: LoadContext): Model { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -69,13 +70,13 @@ export class Model { if (data["connection"] !== undefined && data["connection"] !== null) { instance.connection = Connection.load( data["connection"] as Record, - context, + context.at("connection"), ); } if (data["options"] !== undefined && data["options"] !== null) { instance.options = ModelOptions.load( data["options"] as Record, - context, + context.at("options"), ); } diff --git a/runtime/typescript/packages/core/src/model/model/project-info.ts b/runtime/typescript/packages/core/src/model/model/project-info.ts index 0b7603f37..94636c57d 100644 --- a/runtime/typescript/packages/core/src/model/model/project-info.ts +++ b/runtime/typescript/packages/core/src/model/model/project-info.ts @@ -23,6 +23,7 @@ export class ProjectInfo { data: Record, context?: LoadContext, ): ProjectInfo { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/model/subscription-info.ts b/runtime/typescript/packages/core/src/model/model/subscription-info.ts index 88d417a6c..61e732da6 100644 --- a/runtime/typescript/packages/core/src/model/model/subscription-info.ts +++ b/runtime/typescript/packages/core/src/model/model/subscription-info.ts @@ -23,6 +23,7 @@ export class SubscriptionInfo { data: Record, context?: LoadContext, ): SubscriptionInfo { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/model/token-usage.ts b/runtime/typescript/packages/core/src/model/model/token-usage.ts index c3ced8556..b06561d90 100644 --- a/runtime/typescript/packages/core/src/model/model/token-usage.ts +++ b/runtime/typescript/packages/core/src/model/model/token-usage.ts @@ -29,6 +29,7 @@ export class TokenUsage { data: Record, context?: LoadContext, ): TokenUsage { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/pipeline/compaction-config.ts b/runtime/typescript/packages/core/src/model/pipeline/compaction-config.ts index 61bae55dd..a6240a075 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/compaction-config.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/compaction-config.ts @@ -29,6 +29,7 @@ export class CompactionConfig { data: Record, context?: LoadContext, ): CompactionConfig { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/pipeline/context-candidate.ts b/runtime/typescript/packages/core/src/model/pipeline/context-candidate.ts index c0a682292..99b7bfe48 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/context-candidate.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/context-candidate.ts @@ -28,6 +28,7 @@ export class ContextCandidate { data: Record, context?: LoadContext, ): ContextCandidate { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -43,7 +44,7 @@ export class ContextCandidate { if (data["messages"] !== undefined && data["messages"] !== null) { instance.messages = ContextCandidate.loadMessages( data["messages"] as unknown[], - context, + context.at("messages"), ); } if (data["metadata"] !== undefined && data["metadata"] !== null) { @@ -60,20 +61,31 @@ export class ContextCandidate { data: Record[] | unknown[], context?: LoadContext, ): Message[] { + context ??= new LoadContext({ path: "messages" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Message[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Message.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, role: v }); + result.push(Message.load({ name: k, role: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Message.load(item as Record, context), + return data.map((item, index) => + Message.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/pipeline/context-request.ts b/runtime/typescript/packages/core/src/model/pipeline/context-request.ts index d80ee39c0..9570df1e5 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/context-request.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/context-request.ts @@ -39,10 +39,16 @@ export class ContextRequest { data: Record, context?: LoadContext, ): ContextRequest { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["contextState"] === undefined || data["contextState"] === null) { + throw new Error( + `${context.at("contextState").path}: missing required field`, + ); + } const instance = new ContextRequest(); if (data["sessionId"] !== undefined && data["sessionId"] !== null) { @@ -60,7 +66,7 @@ export class ContextRequest { if (data["messages"] !== undefined && data["messages"] !== null) { instance.messages = ContextRequest.loadMessages( data["messages"] as unknown[], - context, + context.at("messages"), ); } if ( @@ -72,7 +78,7 @@ export class ContextRequest { if (data["contextState"] !== undefined && data["contextState"] !== null) { instance.contextState = InvocationContextState.load( data["contextState"] as Record, - context, + context.at("contextState"), ); } if (data["inputs"] !== undefined && data["inputs"] !== null) { @@ -89,20 +95,31 @@ export class ContextRequest { data: Record[] | unknown[], context?: LoadContext, ): Message[] { + context ??= new LoadContext({ path: "messages" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Message[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Message.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, role: v }); + result.push(Message.load({ name: k, role: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Message.load(item as Record, context), + return data.map((item, index) => + Message.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/pipeline/delegated-state-reference.ts b/runtime/typescript/packages/core/src/model/pipeline/delegated-state-reference.ts index 137e6dc8f..5ec5b3dbf 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/delegated-state-reference.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/delegated-state-reference.ts @@ -27,6 +27,7 @@ export class DelegatedStateReference { data: Record, context?: LoadContext, ): DelegatedStateReference { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/pipeline/engine-checkpoint.ts b/runtime/typescript/packages/core/src/model/pipeline/engine-checkpoint.ts index 65bc673e6..20a2b7829 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/engine-checkpoint.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/engine-checkpoint.ts @@ -57,12 +57,8 @@ export class EngineCheckpoint { if (init?.activeInvocationId !== undefined) { this.activeInvocationId = init.activeInvocationId; } - if (init?.pendingToolRequests !== undefined) { - this.pendingToolRequests = init.pendingToolRequests; - } - if (init?.completedToolResults !== undefined) { - this.completedToolResults = init.completedToolResults; - } + this.pendingToolRequests = init?.pendingToolRequests ?? []; + this.completedToolResults = init?.completedToolResults ?? []; this.completedModelIterations = init?.completedModelIterations ?? 0; this.reconciliationRequired = init?.reconciliationRequired ?? false; if (init?.modelReconciliation !== undefined) { @@ -91,10 +87,16 @@ export class EngineCheckpoint { data: Record, context?: LoadContext, ): EngineCheckpoint { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["contextState"] === undefined || data["contextState"] === null) { + throw new Error( + `${context.at("contextState").path}: missing required field`, + ); + } const instance = new EngineCheckpoint(); if (data["id"] !== undefined && data["id"] !== null) { @@ -127,7 +129,7 @@ export class EngineCheckpoint { if (data["messages"] !== undefined && data["messages"] !== null) { instance.messages = EngineCheckpoint.loadMessages( data["messages"] as unknown[], - context, + context.at("messages"), ); } if ( @@ -151,7 +153,7 @@ export class EngineCheckpoint { ) { instance.pendingToolRequests = EngineCheckpoint.loadPendingToolRequests( data["pendingToolRequests"] as unknown[], - context, + context.at("pendingToolRequests"), ); } if ( @@ -160,7 +162,7 @@ export class EngineCheckpoint { ) { instance.completedToolResults = EngineCheckpoint.loadCompletedToolResults( data["completedToolResults"] as unknown[], - context, + context.at("completedToolResults"), ); } if ( @@ -183,7 +185,7 @@ export class EngineCheckpoint { ) { instance.modelReconciliation = ModelReconciliationState.load( data["modelReconciliation"] as Record, - context, + context.at("modelReconciliation"), ); } if (data["pendingOutput"] !== undefined && data["pendingOutput"] !== null) { @@ -201,7 +203,7 @@ export class EngineCheckpoint { ) { instance.pendingModelResponse = ModelInvocationResponse.load( data["pendingModelResponse"] as Record, - context, + context.at("pendingModelResponse"), ); } if ( @@ -221,7 +223,7 @@ export class EngineCheckpoint { if (data["contextState"] !== undefined && data["contextState"] !== null) { instance.contextState = InvocationContextState.load( data["contextState"] as Record, - context, + context.at("contextState"), ); } if (data["metadata"] !== undefined && data["metadata"] !== null) { @@ -238,20 +240,31 @@ export class EngineCheckpoint { data: Record[] | unknown[], context?: LoadContext, ): Message[] { + context ??= new LoadContext({ path: "messages" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Message[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Message.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, role: v }); + result.push(Message.load({ name: k, role: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Message.load(item as Record, context), + return data.map((item, index) => + Message.load(item as Record, context.atIndex(index)), ); } @@ -271,20 +284,34 @@ export class EngineCheckpoint { data: Record[] | unknown[], context?: LoadContext, ): ModelToolRequest[] { + context ??= new LoadContext({ path: "pendingToolRequests" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: ModelToolRequest[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + ModelToolRequest.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, id: v }); + result.push(ModelToolRequest.load({ name: k, id: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - ModelToolRequest.load(item as Record, context), + return data.map((item, index) => + ModelToolRequest.load( + item as Record, + context.atIndex(index), + ), ); } @@ -296,59 +323,44 @@ export class EngineCheckpoint { context = new SaveContext(); } - if (context.collectionFormat === "array") { - return items.map((item) => item.save(context)); - } - - // Object format: use name as key - const result: Record = {}; - for (const item of items) { - const itemData = item.save(context) as Record; - const name = itemData["name"] as string | undefined; - delete itemData["name"]; - if (name) { - // Check if we can use shorthand (only primary property set) - const shorthand = (item.constructor as typeof ModelToolRequest) - .shorthandProperty; - if ( - context.useShorthand && - shorthand && - Object.keys(itemData).length === 1 && - shorthand in itemData - ) { - result[name] = itemData[shorthand]; - continue; - } - result[name] = itemData; - } else { - // No name, fall back to array format for this item - if (!result["_unnamed"]) { - result["_unnamed"] = []; - } - (result["_unnamed"] as unknown[]).push(itemData); - } - } - return result; + // This type doesn't have a 'name' property, so always use array format + return items.map((item) => item.save(context)); } static loadCompletedToolResults( data: Record[] | unknown[], context?: LoadContext, ): ModelToolResult[] { + context ??= new LoadContext({ path: "completedToolResults" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: ModelToolResult[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + ModelToolResult.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, requestId: v }); + result.push( + ModelToolResult.load({ name: k, requestId: v }, context.at(k)), + ); } } - data = result; + return result; } - return data.map((item) => - ModelToolResult.load(item as Record, context), + return data.map((item, index) => + ModelToolResult.load( + item as Record, + context.atIndex(index), + ), ); } @@ -360,39 +372,8 @@ export class EngineCheckpoint { context = new SaveContext(); } - if (context.collectionFormat === "array") { - return items.map((item) => item.save(context)); - } - - // Object format: use name as key - const result: Record = {}; - for (const item of items) { - const itemData = item.save(context) as Record; - const name = itemData["name"] as string | undefined; - delete itemData["name"]; - if (name) { - // Check if we can use shorthand (only primary property set) - const shorthand = (item.constructor as typeof ModelToolResult) - .shorthandProperty; - if ( - context.useShorthand && - shorthand && - Object.keys(itemData).length === 1 && - shorthand in itemData - ) { - result[name] = itemData[shorthand]; - continue; - } - result[name] = itemData; - } else { - // No name, fall back to array format for this item - if (!result["_unnamed"]) { - result["_unnamed"] = []; - } - (result["_unnamed"] as unknown[]).push(itemData); - } - } - return result; + // This type doesn't have a 'name' property, so always use array format + return items.map((item) => item.save(context)); } //#endregion diff --git a/runtime/typescript/packages/core/src/model/pipeline/engine-durability-port.ts b/runtime/typescript/packages/core/src/model/pipeline/engine-durability-port.ts new file mode 100644 index 000000000..f94eecc08 --- /dev/null +++ b/runtime/typescript/packages/core/src/model/pipeline/engine-durability-port.ts @@ -0,0 +1,17 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// WARNING: This is an auto-generated file. DO NOT EDIT THIS FILE DIRECTLY. + +import { EngineCheckpoint } from "./engine-checkpoint"; +import { EngineEvent } from "./engine-event"; + +/** Persists semantic engine events and checkpoints without runtime cancellation. */ +export interface EngineDurabilityPort { + /** Append one semantic engine event durably */ + append(event: EngineEvent): Promise; + /** Atomically append semantic engine events and persist the checkpoint that reflects them */ + appendWithCheckpoint( + events: EngineEvent[], + checkpoint: EngineCheckpoint, + ): Promise; +} diff --git a/runtime/typescript/packages/core/src/model/pipeline/engine-event.ts b/runtime/typescript/packages/core/src/model/pipeline/engine-event.ts index 114f08157..29c8932d1 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/engine-event.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/engine-event.ts @@ -74,6 +74,7 @@ export class EngineEvent { data: Record, context?: LoadContext, ): EngineEvent { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/pipeline/engine-permission-decision.ts b/runtime/typescript/packages/core/src/model/pipeline/engine-permission-decision.ts index b5e12495c..cede765cb 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/engine-permission-decision.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/engine-permission-decision.ts @@ -27,6 +27,7 @@ export class EnginePermissionDecision { data: Record, context?: LoadContext, ): EnginePermissionDecision { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/pipeline/engine-permission-port.ts b/runtime/typescript/packages/core/src/model/pipeline/engine-permission-port.ts new file mode 100644 index 000000000..b4b02bf4a --- /dev/null +++ b/runtime/typescript/packages/core/src/model/pipeline/engine-permission-port.ts @@ -0,0 +1,15 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// WARNING: This is an auto-generated file. DO NOT EDIT THIS FILE DIRECTLY. + +import { EnginePermissionDecision } from "./engine-permission-decision"; +import { ModelToolRequest } from "./model-tool-request"; + +/** Authorizes model-requested tools at a runtime cancellation boundary. */ +export interface EnginePermissionPort { + /** Authorize one model-requested tool before execution */ + authorize( + request: ModelToolRequest, + signal?: AbortSignal, + ): Promise; +} diff --git a/runtime/typescript/packages/core/src/model/pipeline/engine-post-commit-port.ts b/runtime/typescript/packages/core/src/model/pipeline/engine-post-commit-port.ts new file mode 100644 index 000000000..1aec1d5d6 --- /dev/null +++ b/runtime/typescript/packages/core/src/model/pipeline/engine-post-commit-port.ts @@ -0,0 +1,15 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// WARNING: This is an auto-generated file. DO NOT EDIT THIS FILE DIRECTLY. + +import { TurnCommit } from "./turn-commit"; + +/** Runs non-fatal host effects after a turn is durably committed. */ +export interface EnginePostCommitPort { + /** Run one idempotent host effect after the turn is durably committed */ + afterCommit( + effectId: string, + commit: TurnCommit, + signal?: AbortSignal, + ): Promise; +} diff --git a/runtime/typescript/packages/core/src/model/pipeline/engine-tool-port.ts b/runtime/typescript/packages/core/src/model/pipeline/engine-tool-port.ts new file mode 100644 index 000000000..96e9a7af8 --- /dev/null +++ b/runtime/typescript/packages/core/src/model/pipeline/engine-tool-port.ts @@ -0,0 +1,15 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// WARNING: This is an auto-generated file. DO NOT EDIT THIS FILE DIRECTLY. + +import { ModelToolRequest } from "./model-tool-request"; +import { ModelToolResult } from "./model-tool-result"; + +/** Executes authorized model-requested tools at a runtime cancellation boundary. */ +export interface EngineToolPort { + /** Execute one authorized model-requested tool */ + execute( + request: ModelToolRequest, + signal?: AbortSignal, + ): Promise; +} diff --git a/runtime/typescript/packages/core/src/model/pipeline/executor.ts b/runtime/typescript/packages/core/src/model/pipeline/executor.ts index a288c30c2..c7b8895e7 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/executor.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/executor.ts @@ -9,9 +9,17 @@ import { ToolCall } from "../conversation/tool-call"; /** Calls an LLM provider with messages and returns the raw provider response. */ export interface Executor { /** Call an LLM provider with messages and return the raw response */ - execute(agent: Prompty, messages: Message[]): Promise; + execute( + agent: Prompty, + messages: Message[], + signal?: AbortSignal, + ): Promise; /** Call an LLM provider and return a streaming response. Returns a language-specific async iterable/stream of raw chunks. Not all providers support streaming; the default implementation should signal lack of support. */ - executeStream?(agent: Prompty, messages: Message[]): Promise; + executeStream?( + agent: Prompty, + messages: Message[], + signal?: AbortSignal, + ): Promise; /** Format tool call results into messages for the next iteration */ formatToolMessages( rawResponse: unknown, diff --git a/runtime/typescript/packages/core/src/model/pipeline/final-output-policy-request.ts b/runtime/typescript/packages/core/src/model/pipeline/final-output-policy-request.ts index 402e0191f..370929fb1 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/final-output-policy-request.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/final-output-policy-request.ts @@ -34,6 +34,7 @@ export class FinalOutputPolicyRequest { data: Record, context?: LoadContext, ): FinalOutputPolicyRequest { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -52,7 +53,7 @@ export class FinalOutputPolicyRequest { if (data["messages"] !== undefined && data["messages"] !== null) { instance.messages = FinalOutputPolicyRequest.loadMessages( data["messages"] as unknown[], - context, + context.at("messages"), ); } if (data["output"] !== undefined && data["output"] !== null) { @@ -72,20 +73,31 @@ export class FinalOutputPolicyRequest { data: Record[] | unknown[], context?: LoadContext, ): Message[] { + context ??= new LoadContext({ path: "messages" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Message[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Message.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, role: v }); + result.push(Message.load({ name: k, role: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Message.load(item as Record, context), + return data.map((item, index) => + Message.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/pipeline/final-output-policy-result.ts b/runtime/typescript/packages/core/src/model/pipeline/final-output-policy-result.ts index 15a2136f8..22117f4f1 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/final-output-policy-result.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/final-output-policy-result.ts @@ -25,6 +25,7 @@ export class FinalOutputPolicyResult { data: Record, context?: LoadContext, ): FinalOutputPolicyResult { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/pipeline/host-policy-request.ts b/runtime/typescript/packages/core/src/model/pipeline/host-policy-request.ts index 6698d8260..20e53d38e 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/host-policy-request.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/host-policy-request.ts @@ -32,6 +32,7 @@ export class HostPolicyRequest { data: Record, context?: LoadContext, ): HostPolicyRequest { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -50,7 +51,7 @@ export class HostPolicyRequest { if (data["messages"] !== undefined && data["messages"] !== null) { instance.messages = HostPolicyRequest.loadMessages( data["messages"] as unknown[], - context, + context.at("messages"), ); } if ( @@ -73,20 +74,31 @@ export class HostPolicyRequest { data: Record[] | unknown[], context?: LoadContext, ): Message[] { + context ??= new LoadContext({ path: "messages" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Message[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Message.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, role: v }); + result.push(Message.load({ name: k, role: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Message.load(item as Record, context), + return data.map((item, index) => + Message.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/pipeline/host-policy-result.ts b/runtime/typescript/packages/core/src/model/pipeline/host-policy-result.ts index e18d53c27..82f1f8590 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/host-policy-result.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/host-policy-result.ts @@ -26,6 +26,7 @@ export class HostPolicyResult { data: Record, context?: LoadContext, ): HostPolicyResult { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -35,7 +36,7 @@ export class HostPolicyResult { if (data["messages"] !== undefined && data["messages"] !== null) { instance.messages = HostPolicyResult.loadMessages( data["messages"] as unknown[], - context, + context.at("messages"), ); } if ( @@ -58,20 +59,31 @@ export class HostPolicyResult { data: Record[] | unknown[], context?: LoadContext, ): Message[] { + context ??= new LoadContext({ path: "messages" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Message[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Message.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, role: v }); + result.push(Message.load({ name: k, role: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Message.load(item as Record, context), + return data.map((item, index) => + Message.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/pipeline/index.ts b/runtime/typescript/packages/core/src/model/pipeline/index.ts index 2df49ac24..0a38b5e8a 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/index.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/index.ts @@ -17,6 +17,10 @@ export { EngineCheckpoint } from "./engine-checkpoint"; export { ResumeContext } from "./resume-context"; export { TurnCommit } from "./turn-commit"; export { TurnEngineResult } from "./turn-engine-result"; +export type { EnginePermissionPort } from "./engine-permission-port"; +export type { EngineToolPort } from "./engine-tool-port"; +export type { EngineDurabilityPort } from "./engine-durability-port"; +export type { EnginePostCommitPort } from "./engine-post-commit-port"; export { HostPolicyRequest } from "./host-policy-request"; export { HostPolicyResult } from "./host-policy-result"; export { FinalOutputPolicyRequest } from "./final-output-policy-request"; diff --git a/runtime/typescript/packages/core/src/model/pipeline/invocation-context-decision.ts b/runtime/typescript/packages/core/src/model/pipeline/invocation-context-decision.ts index 454674956..349bdb3f4 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/invocation-context-decision.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/invocation-context-decision.ts @@ -37,6 +37,7 @@ export class InvocationContextDecision { data: Record, context?: LoadContext, ): InvocationContextDecision { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/pipeline/invocation-context-state.ts b/runtime/typescript/packages/core/src/model/pipeline/invocation-context-state.ts index 314311fdb..15d3f32ce 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/invocation-context-state.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/invocation-context-state.ts @@ -15,9 +15,7 @@ export class InvocationContextState { constructor(init?: Partial) { this.portability = init?.portability ?? "portable"; - if (init?.delegatedState !== undefined) { - this.delegatedState = init.delegatedState; - } + this.delegatedState = init?.delegatedState ?? []; } //#region Load Methods @@ -26,6 +24,7 @@ export class InvocationContextState { data: Record, context?: LoadContext, ): InvocationContextState { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -43,7 +42,7 @@ export class InvocationContextState { ) { instance.delegatedState = InvocationContextState.loadDelegatedState( data["delegatedState"] as unknown[], - context, + context.at("delegatedState"), ); } @@ -57,20 +56,39 @@ export class InvocationContextState { data: Record[] | unknown[], context?: LoadContext, ): DelegatedStateReference[] { + context ??= new LoadContext({ path: "delegatedState" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: DelegatedStateReference[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + DelegatedStateReference.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, provider: v }); + result.push( + DelegatedStateReference.load( + { name: k, provider: v }, + context.at(k), + ), + ); } } - data = result; + return result; } - return data.map((item) => - DelegatedStateReference.load(item as Record, context), + return data.map((item, index) => + DelegatedStateReference.load( + item as Record, + context.atIndex(index), + ), ); } diff --git a/runtime/typescript/packages/core/src/model/pipeline/model-invocation-context-snapshot.ts b/runtime/typescript/packages/core/src/model/pipeline/model-invocation-context-snapshot.ts index ff7dcad93..9a5280899 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/model-invocation-context-snapshot.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/model-invocation-context-snapshot.ts @@ -28,9 +28,7 @@ export class ModelInvocationContextSnapshot { this.invocationId = init?.invocationId ?? ""; this.iteration = init?.iteration ?? 0; this.messages = init?.messages ?? []; - if (init?.decisions !== undefined) { - this.decisions = init.decisions; - } + this.decisions = init?.decisions ?? []; this.stablePrefixMessages = init?.stablePrefixMessages ?? 0; if (init?.contextState !== undefined) { this.contextState = init.contextState; @@ -46,10 +44,16 @@ export class ModelInvocationContextSnapshot { data: Record, context?: LoadContext, ): ModelInvocationContextSnapshot { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["contextState"] === undefined || data["contextState"] === null) { + throw new Error( + `${context.at("contextState").path}: missing required field`, + ); + } const instance = new ModelInvocationContextSnapshot(); if (data["id"] !== undefined && data["id"] !== null) { @@ -70,13 +74,13 @@ export class ModelInvocationContextSnapshot { if (data["messages"] !== undefined && data["messages"] !== null) { instance.messages = ModelInvocationContextSnapshot.loadMessages( data["messages"] as unknown[], - context, + context.at("messages"), ); } if (data["decisions"] !== undefined && data["decisions"] !== null) { instance.decisions = ModelInvocationContextSnapshot.loadDecisions( data["decisions"] as unknown[], - context, + context.at("decisions"), ); } if ( @@ -88,7 +92,7 @@ export class ModelInvocationContextSnapshot { if (data["contextState"] !== undefined && data["contextState"] !== null) { instance.contextState = InvocationContextState.load( data["contextState"] as Record, - context, + context.at("contextState"), ); } if (data["metadata"] !== undefined && data["metadata"] !== null) { @@ -105,20 +109,31 @@ export class ModelInvocationContextSnapshot { data: Record[] | unknown[], context?: LoadContext, ): Message[] { + context ??= new LoadContext({ path: "messages" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Message[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Message.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, role: v }); + result.push(Message.load({ name: k, role: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Message.load(item as Record, context), + return data.map((item, index) => + Message.load(item as Record, context.atIndex(index)), ); } @@ -138,20 +153,39 @@ export class ModelInvocationContextSnapshot { data: Record[] | unknown[], context?: LoadContext, ): InvocationContextDecision[] { + context ??= new LoadContext({ path: "decisions" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: InvocationContextDecision[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + InvocationContextDecision.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, candidateId: v }); + result.push( + InvocationContextDecision.load( + { name: k, candidateId: v }, + context.at(k), + ), + ); } } - data = result; + return result; } - return data.map((item) => - InvocationContextDecision.load(item as Record, context), + return data.map((item, index) => + InvocationContextDecision.load( + item as Record, + context.atIndex(index), + ), ); } diff --git a/runtime/typescript/packages/core/src/model/pipeline/model-invocation-request.ts b/runtime/typescript/packages/core/src/model/pipeline/model-invocation-request.ts index c0e8fbce4..6b539ba1e 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/model-invocation-request.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/model-invocation-request.ts @@ -22,16 +22,20 @@ export class ModelInvocationRequest { data: Record, context?: LoadContext, ): ModelInvocationRequest { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["context"] === undefined || data["context"] === null) { + throw new Error(`${context.at("context").path}: missing required field`); + } const instance = new ModelInvocationRequest(); if (data["context"] !== undefined && data["context"] !== null) { instance.context = ModelInvocationContextSnapshot.load( data["context"] as Record, - context, + context.at("context"), ); } diff --git a/runtime/typescript/packages/core/src/model/pipeline/model-invocation-response.ts b/runtime/typescript/packages/core/src/model/pipeline/model-invocation-response.ts index fe907e3fa..64b492b7b 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/model-invocation-response.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/model-invocation-response.ts @@ -25,12 +25,8 @@ export class ModelInvocationResponse { if (init?.usage !== undefined) { this.usage = init.usage; } - if (init?.assistantMessages !== undefined) { - this.assistantMessages = init.assistantMessages; - } - if (init?.toolRequests !== undefined) { - this.toolRequests = init.toolRequests; - } + this.assistantMessages = init?.assistantMessages ?? []; + this.toolRequests = init?.toolRequests ?? []; if (init?.nextContextState !== undefined) { this.nextContextState = init.nextContextState; } @@ -45,6 +41,7 @@ export class ModelInvocationResponse { data: Record, context?: LoadContext, ): ModelInvocationResponse { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -57,7 +54,7 @@ export class ModelInvocationResponse { if (data["usage"] !== undefined && data["usage"] !== null) { instance.usage = InvocationUsage.load( data["usage"] as Record, - context, + context.at("usage"), ); } if ( @@ -67,13 +64,13 @@ export class ModelInvocationResponse { instance.assistantMessages = ModelInvocationResponse.loadAssistantMessages( data["assistantMessages"] as unknown[], - context, + context.at("assistantMessages"), ); } if (data["toolRequests"] !== undefined && data["toolRequests"] !== null) { instance.toolRequests = ModelInvocationResponse.loadToolRequests( data["toolRequests"] as unknown[], - context, + context.at("toolRequests"), ); } if ( @@ -82,7 +79,7 @@ export class ModelInvocationResponse { ) { instance.nextContextState = InvocationContextState.load( data["nextContextState"] as Record, - context, + context.at("nextContextState"), ); } if (data["metadata"] !== undefined && data["metadata"] !== null) { @@ -99,20 +96,31 @@ export class ModelInvocationResponse { data: Record[] | unknown[], context?: LoadContext, ): Message[] { + context ??= new LoadContext({ path: "assistantMessages" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Message[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Message.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, role: v }); + result.push(Message.load({ name: k, role: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Message.load(item as Record, context), + return data.map((item, index) => + Message.load(item as Record, context.atIndex(index)), ); } @@ -132,20 +140,34 @@ export class ModelInvocationResponse { data: Record[] | unknown[], context?: LoadContext, ): ModelToolRequest[] { + context ??= new LoadContext({ path: "toolRequests" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: ModelToolRequest[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + ModelToolRequest.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, id: v }); + result.push(ModelToolRequest.load({ name: k, id: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - ModelToolRequest.load(item as Record, context), + return data.map((item, index) => + ModelToolRequest.load( + item as Record, + context.atIndex(index), + ), ); } @@ -157,39 +179,8 @@ export class ModelInvocationResponse { context = new SaveContext(); } - if (context.collectionFormat === "array") { - return items.map((item) => item.save(context)); - } - - // Object format: use name as key - const result: Record = {}; - for (const item of items) { - const itemData = item.save(context) as Record; - const name = itemData["name"] as string | undefined; - delete itemData["name"]; - if (name) { - // Check if we can use shorthand (only primary property set) - const shorthand = (item.constructor as typeof ModelToolRequest) - .shorthandProperty; - if ( - context.useShorthand && - shorthand && - Object.keys(itemData).length === 1 && - shorthand in itemData - ) { - result[name] = itemData[shorthand]; - continue; - } - result[name] = itemData; - } else { - // No name, fall back to array format for this item - if (!result["_unnamed"]) { - result["_unnamed"] = []; - } - (result["_unnamed"] as unknown[]).push(itemData); - } - } - return result; + // This type doesn't have a 'name' property, so always use array format + return items.map((item) => item.save(context)); } //#endregion diff --git a/runtime/typescript/packages/core/src/model/pipeline/model-reconciliation-state.ts b/runtime/typescript/packages/core/src/model/pipeline/model-reconciliation-state.ts index b2d1915b9..dd15d8d5c 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/model-reconciliation-state.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/model-reconciliation-state.ts @@ -32,10 +32,14 @@ export class ModelReconciliationState { data: Record, context?: LoadContext, ): ModelReconciliationState { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["request"] === undefined || data["request"] === null) { + throw new Error(`${context.at("request").path}: missing required field`); + } const instance = new ModelReconciliationState(); if (data["invocationId"] !== undefined && data["invocationId"] !== null) { @@ -44,7 +48,7 @@ export class ModelReconciliationState { if (data["request"] !== undefined && data["request"] !== null) { instance.request = ModelInvocationRequest.load( data["request"] as Record, - context, + context.at("request"), ); } if (data["failedAttempt"] !== undefined && data["failedAttempt"] !== null) { diff --git a/runtime/typescript/packages/core/src/model/pipeline/model-tool-request.ts b/runtime/typescript/packages/core/src/model/pipeline/model-tool-request.ts index 346d1a9ac..152d57ea1 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/model-tool-request.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/model-tool-request.ts @@ -29,6 +29,7 @@ export class ModelToolRequest { data: Record, context?: LoadContext, ): ModelToolRequest { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/pipeline/model-tool-result.ts b/runtime/typescript/packages/core/src/model/pipeline/model-tool-result.ts index 1df3ad098..d50ee7605 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/model-tool-result.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/model-tool-result.ts @@ -37,6 +37,7 @@ export class ModelToolResult { data: Record, context?: LoadContext, ): ModelToolResult { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/pipeline/replay-journal-record.ts b/runtime/typescript/packages/core/src/model/pipeline/replay-journal-record.ts index f7971dbfa..11e14cda2 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/replay-journal-record.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/replay-journal-record.ts @@ -66,6 +66,7 @@ export class ReplayJournalRecord { data: Record, context?: LoadContext, ): ReplayJournalRecord { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/pipeline/replay-mismatch.ts b/runtime/typescript/packages/core/src/model/pipeline/replay-mismatch.ts index 4b844f0a4..82a9c2ee7 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/replay-mismatch.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/replay-mismatch.ts @@ -30,6 +30,7 @@ export class ReplayMismatch { data: Record, context?: LoadContext, ): ReplayMismatch { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -42,13 +43,13 @@ export class ReplayMismatch { if (data["expected"] !== undefined && data["expected"] !== null) { instance.expected = ReplayJournalRecord.load( data["expected"] as Record, - context, + context.at("expected"), ); } if (data["actual"] !== undefined && data["actual"] !== null) { instance.actual = ReplayJournalRecord.load( data["actual"] as Record, - context, + context.at("actual"), ); } if (data["message"] !== undefined && data["message"] !== null) { diff --git a/runtime/typescript/packages/core/src/model/pipeline/replay-verification-request.ts b/runtime/typescript/packages/core/src/model/pipeline/replay-verification-request.ts index 079515793..2446c1da3 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/replay-verification-request.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/replay-verification-request.ts @@ -22,6 +22,7 @@ export class ReplayVerificationRequest { data: Record, context?: LoadContext, ): ReplayVerificationRequest { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -31,13 +32,13 @@ export class ReplayVerificationRequest { if (data["expected"] !== undefined && data["expected"] !== null) { instance.expected = ReplayVerificationRequest.loadExpected( data["expected"] as unknown[], - context, + context.at("expected"), ); } if (data["actual"] !== undefined && data["actual"] !== null) { instance.actual = ReplayVerificationRequest.loadActual( data["actual"] as unknown[], - context, + context.at("actual"), ); } @@ -51,20 +52,36 @@ export class ReplayVerificationRequest { data: Record[] | unknown[], context?: LoadContext, ): ReplayJournalRecord[] { + context ??= new LoadContext({ path: "expected" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: ReplayJournalRecord[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + ReplayJournalRecord.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, kind: v }); + result.push( + ReplayJournalRecord.load({ name: k, kind: v }, context.at(k)), + ); } } - data = result; + return result; } - return data.map((item) => - ReplayJournalRecord.load(item as Record, context), + return data.map((item, index) => + ReplayJournalRecord.load( + item as Record, + context.atIndex(index), + ), ); } @@ -84,20 +101,36 @@ export class ReplayVerificationRequest { data: Record[] | unknown[], context?: LoadContext, ): ReplayJournalRecord[] { + context ??= new LoadContext({ path: "actual" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: ReplayJournalRecord[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + ReplayJournalRecord.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, kind: v }); + result.push( + ReplayJournalRecord.load({ name: k, kind: v }, context.at(k)), + ); } } - data = result; + return result; } - return data.map((item) => - ReplayJournalRecord.load(item as Record, context), + return data.map((item, index) => + ReplayJournalRecord.load( + item as Record, + context.atIndex(index), + ), ); } diff --git a/runtime/typescript/packages/core/src/model/pipeline/replay-verification-result.ts b/runtime/typescript/packages/core/src/model/pipeline/replay-verification-result.ts index 426c680e2..fa9573066 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/replay-verification-result.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/replay-verification-result.ts @@ -17,9 +17,7 @@ export class ReplayVerificationResult { constructor(init?: Partial) { this.status = init?.status ?? "passed"; - if (init?.mismatches !== undefined) { - this.mismatches = init.mismatches; - } + this.mismatches = init?.mismatches ?? []; this.expectedCount = init?.expectedCount ?? 0; this.actualCount = init?.actualCount ?? 0; } @@ -30,6 +28,7 @@ export class ReplayVerificationResult { data: Record, context?: LoadContext, ): ReplayVerificationResult { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -42,7 +41,7 @@ export class ReplayVerificationResult { if (data["mismatches"] !== undefined && data["mismatches"] !== null) { instance.mismatches = ReplayVerificationResult.loadMismatches( data["mismatches"] as unknown[], - context, + context.at("mismatches"), ); } if (data["expectedCount"] !== undefined && data["expectedCount"] !== null) { @@ -62,20 +61,36 @@ export class ReplayVerificationResult { data: Record[] | unknown[], context?: LoadContext, ): ReplayMismatch[] { + context ??= new LoadContext({ path: "mismatches" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: ReplayMismatch[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + ReplayMismatch.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, index: v }); + result.push( + ReplayMismatch.load({ name: k, index: v }, context.at(k)), + ); } } - data = result; + return result; } - return data.map((item) => - ReplayMismatch.load(item as Record, context), + return data.map((item, index) => + ReplayMismatch.load( + item as Record, + context.atIndex(index), + ), ); } diff --git a/runtime/typescript/packages/core/src/model/pipeline/resume-context.ts b/runtime/typescript/packages/core/src/model/pipeline/resume-context.ts index 6ca01399e..f60c6046e 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/resume-context.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/resume-context.ts @@ -32,16 +32,22 @@ export class ResumeContext { data: Record, context?: LoadContext, ): ResumeContext { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["checkpoint"] === undefined || data["checkpoint"] === null) { + throw new Error( + `${context.at("checkpoint").path}: missing required field`, + ); + } const instance = new ResumeContext(); if (data["checkpoint"] !== undefined && data["checkpoint"] !== null) { instance.checkpoint = EngineCheckpoint.load( data["checkpoint"] as Record, - context, + context.at("checkpoint"), ); } if (data["maxIterations"] !== undefined && data["maxIterations"] !== null) { diff --git a/runtime/typescript/packages/core/src/model/pipeline/retry-policy-request.ts b/runtime/typescript/packages/core/src/model/pipeline/retry-policy-request.ts index e1c2c6fc6..db959727c 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/retry-policy-request.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/retry-policy-request.ts @@ -25,6 +25,7 @@ export class RetryPolicyRequest { data: Record, context?: LoadContext, ): RetryPolicyRequest { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/pipeline/run-turn-request.ts b/runtime/typescript/packages/core/src/model/pipeline/run-turn-request.ts index e4a95ad84..a6ad3f063 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/run-turn-request.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/run-turn-request.ts @@ -30,6 +30,7 @@ export class RunTurnRequest { data: Record, context?: LoadContext, ): RunTurnRequest { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -48,7 +49,7 @@ export class RunTurnRequest { if (data["options"] !== undefined && data["options"] !== null) { instance.options = TurnOptions.load( data["options"] as Record, - context, + context.at("options"), ); } diff --git a/runtime/typescript/packages/core/src/model/pipeline/run-turn-result.ts b/runtime/typescript/packages/core/src/model/pipeline/run-turn-result.ts index 5067c0745..71bb93520 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/run-turn-result.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/run-turn-result.ts @@ -27,12 +27,8 @@ export class RunTurnResult { this.output = init.output; } this.iterations = init?.iterations ?? 0; - if (init?.toolResults !== undefined) { - this.toolResults = init.toolResults; - } - if (init?.checkpoints !== undefined) { - this.checkpoints = init.checkpoints; - } + this.toolResults = init?.toolResults ?? []; + this.checkpoints = init?.checkpoints ?? []; } //#region Load Methods @@ -41,6 +37,7 @@ export class RunTurnResult { data: Record, context?: LoadContext, ): RunTurnResult { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -65,13 +62,13 @@ export class RunTurnResult { if (data["toolResults"] !== undefined && data["toolResults"] !== null) { instance.toolResults = RunTurnResult.loadToolResults( data["toolResults"] as unknown[], - context, + context.at("toolResults"), ); } if (data["checkpoints"] !== undefined && data["checkpoints"] !== null) { instance.checkpoints = RunTurnResult.loadCheckpoints( data["checkpoints"] as unknown[], - context, + context.at("checkpoints"), ); } @@ -85,20 +82,36 @@ export class RunTurnResult { data: Record[] | unknown[], context?: LoadContext, ): HostToolResult[] { + context ??= new LoadContext({ path: "toolResults" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: HostToolResult[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + HostToolResult.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, requestId: v }); + result.push( + HostToolResult.load({ name: k, requestId: v }, context.at(k)), + ); } } - data = result; + return result; } - return data.map((item) => - HostToolResult.load(item as Record, context), + return data.map((item, index) => + HostToolResult.load( + item as Record, + context.atIndex(index), + ), ); } @@ -118,20 +131,31 @@ export class RunTurnResult { data: Record[] | unknown[], context?: LoadContext, ): Checkpoint[] { + context ??= new LoadContext({ path: "checkpoints" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Checkpoint[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Checkpoint.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, id: v }); + result.push(Checkpoint.load({ name: k, id: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Checkpoint.load(item as Record, context), + return data.map((item, index) => + Checkpoint.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/pipeline/turn-commit.ts b/runtime/typescript/packages/core/src/model/pipeline/turn-commit.ts index 26b490e2a..122936c5a 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/turn-commit.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/turn-commit.ts @@ -50,10 +50,16 @@ export class TurnCommit { data: Record, context?: LoadContext, ): TurnCommit { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["contextState"] === undefined || data["contextState"] === null) { + throw new Error( + `${context.at("contextState").path}: missing required field`, + ); + } const instance = new TurnCommit(); if (data["sessionId"] !== undefined && data["sessionId"] !== null) { @@ -71,7 +77,7 @@ export class TurnCommit { if (data["messages"] !== undefined && data["messages"] !== null) { instance.messages = TurnCommit.loadMessages( data["messages"] as unknown[], - context, + context.at("messages"), ); } if (data["iterations"] !== undefined && data["iterations"] !== null) { @@ -83,7 +89,7 @@ export class TurnCommit { if (data["contextState"] !== undefined && data["contextState"] !== null) { instance.contextState = InvocationContextState.load( data["contextState"] as Record, - context, + context.at("contextState"), ); } if ( @@ -92,7 +98,7 @@ export class TurnCommit { ) { instance.modelReconciliation = ModelReconciliationState.load( data["modelReconciliation"] as Record, - context, + context.at("modelReconciliation"), ); } @@ -106,20 +112,31 @@ export class TurnCommit { data: Record[] | unknown[], context?: LoadContext, ): Message[] { + context ??= new LoadContext({ path: "messages" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Message[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Message.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, role: v }); + result.push(Message.load({ name: k, role: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Message.load(item as Record, context), + return data.map((item, index) => + Message.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/pipeline/turn-engine-result.ts b/runtime/typescript/packages/core/src/model/pipeline/turn-engine-result.ts index 73ce241b3..9bd60b80e 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/turn-engine-result.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/turn-engine-result.ts @@ -19,12 +19,8 @@ export class TurnEngineResult { if (init?.commit !== undefined) { this.commit = init.commit; } - if (init?.snapshots !== undefined) { - this.snapshots = init.snapshots; - } - if (init?.toolResults !== undefined) { - this.toolResults = init.toolResults; - } + this.snapshots = init?.snapshots ?? []; + this.toolResults = init?.toolResults ?? []; if (init?.postCommitError !== undefined) { this.postCommitError = init.postCommitError; } @@ -36,28 +32,32 @@ export class TurnEngineResult { data: Record, context?: LoadContext, ): TurnEngineResult { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["commit"] === undefined || data["commit"] === null) { + throw new Error(`${context.at("commit").path}: missing required field`); + } const instance = new TurnEngineResult(); if (data["commit"] !== undefined && data["commit"] !== null) { instance.commit = TurnCommit.load( data["commit"] as Record, - context, + context.at("commit"), ); } if (data["snapshots"] !== undefined && data["snapshots"] !== null) { instance.snapshots = TurnEngineResult.loadSnapshots( data["snapshots"] as unknown[], - context, + context.at("snapshots"), ); } if (data["toolResults"] !== undefined && data["toolResults"] !== null) { instance.toolResults = TurnEngineResult.loadToolResults( data["toolResults"] as unknown[], - context, + context.at("toolResults"), ); } if ( @@ -77,22 +77,38 @@ export class TurnEngineResult { data: Record[] | unknown[], context?: LoadContext, ): ModelInvocationContextSnapshot[] { + context ??= new LoadContext({ path: "snapshots" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: ModelInvocationContextSnapshot[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + ModelInvocationContextSnapshot.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, id: v }); + result.push( + ModelInvocationContextSnapshot.load( + { name: k, id: v }, + context.at(k), + ), + ); } } - data = result; + return result; } - return data.map((item) => + return data.map((item, index) => ModelInvocationContextSnapshot.load( item as Record, - context, + context.atIndex(index), ), ); } @@ -113,20 +129,36 @@ export class TurnEngineResult { data: Record[] | unknown[], context?: LoadContext, ): ModelToolResult[] { + context ??= new LoadContext({ path: "toolResults" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: ModelToolResult[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + ModelToolResult.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, requestId: v }); + result.push( + ModelToolResult.load({ name: k, requestId: v }, context.at(k)), + ); } } - data = result; + return result; } - return data.map((item) => - ModelToolResult.load(item as Record, context), + return data.map((item, index) => + ModelToolResult.load( + item as Record, + context.atIndex(index), + ), ); } @@ -138,39 +170,8 @@ export class TurnEngineResult { context = new SaveContext(); } - if (context.collectionFormat === "array") { - return items.map((item) => item.save(context)); - } - - // Object format: use name as key - const result: Record = {}; - for (const item of items) { - const itemData = item.save(context) as Record; - const name = itemData["name"] as string | undefined; - delete itemData["name"]; - if (name) { - // Check if we can use shorthand (only primary property set) - const shorthand = (item.constructor as typeof ModelToolResult) - .shorthandProperty; - if ( - context.useShorthand && - shorthand && - Object.keys(itemData).length === 1 && - shorthand in itemData - ) { - result[name] = itemData[shorthand]; - continue; - } - result[name] = itemData; - } else { - // No name, fall back to array format for this item - if (!result["_unnamed"]) { - result["_unnamed"] = []; - } - (result["_unnamed"] as unknown[]).push(itemData); - } - } - return result; + // This type doesn't have a 'name' property, so always use array format + return items.map((item) => item.save(context)); } //#endregion diff --git a/runtime/typescript/packages/core/src/model/pipeline/turn-model-request.ts b/runtime/typescript/packages/core/src/model/pipeline/turn-model-request.ts index 3adee64db..4c2f5f200 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/turn-model-request.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/turn-model-request.ts @@ -26,9 +26,7 @@ export class TurnModelRequest { if (init?.options !== undefined) { this.options = init.options; } - if (init?.toolResults !== undefined) { - this.toolResults = init.toolResults; - } + this.toolResults = init?.toolResults ?? []; } //#region Load Methods @@ -37,6 +35,7 @@ export class TurnModelRequest { data: Record, context?: LoadContext, ): TurnModelRequest { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -58,13 +57,13 @@ export class TurnModelRequest { if (data["options"] !== undefined && data["options"] !== null) { instance.options = TurnOptions.load( data["options"] as Record, - context, + context.at("options"), ); } if (data["toolResults"] !== undefined && data["toolResults"] !== null) { instance.toolResults = TurnModelRequest.loadToolResults( data["toolResults"] as unknown[], - context, + context.at("toolResults"), ); } @@ -78,20 +77,36 @@ export class TurnModelRequest { data: Record[] | unknown[], context?: LoadContext, ): HostToolResult[] { + context ??= new LoadContext({ path: "toolResults" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: HostToolResult[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + HostToolResult.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, requestId: v }); + result.push( + HostToolResult.load({ name: k, requestId: v }, context.at(k)), + ); } } - data = result; + return result; } - return data.map((item) => - HostToolResult.load(item as Record, context), + return data.map((item, index) => + HostToolResult.load( + item as Record, + context.atIndex(index), + ), ); } diff --git a/runtime/typescript/packages/core/src/model/pipeline/turn-model-response.ts b/runtime/typescript/packages/core/src/model/pipeline/turn-model-response.ts index ce79c2d79..a12ba56f2 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/turn-model-response.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/turn-model-response.ts @@ -21,9 +21,7 @@ export class TurnModelResponse { if (init?.usage !== undefined) { this.usage = init.usage; } - if (init?.toolRequests !== undefined) { - this.toolRequests = init.toolRequests; - } + this.toolRequests = init?.toolRequests ?? []; if (init?.checkpointState !== undefined) { this.checkpointState = init.checkpointState; } @@ -35,6 +33,7 @@ export class TurnModelResponse { data: Record, context?: LoadContext, ): TurnModelResponse { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -47,13 +46,13 @@ export class TurnModelResponse { if (data["usage"] !== undefined && data["usage"] !== null) { instance.usage = InvocationUsage.load( data["usage"] as Record, - context, + context.at("usage"), ); } if (data["toolRequests"] !== undefined && data["toolRequests"] !== null) { instance.toolRequests = TurnModelResponse.loadToolRequests( data["toolRequests"] as unknown[], - context, + context.at("toolRequests"), ); } if ( @@ -76,20 +75,36 @@ export class TurnModelResponse { data: Record[] | unknown[], context?: LoadContext, ): HostToolRequest[] { + context ??= new LoadContext({ path: "toolRequests" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: HostToolRequest[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + HostToolRequest.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, requestId: v }); + result.push( + HostToolRequest.load({ name: k, requestId: v }, context.at(k)), + ); } } - data = result; + return result; } - return data.map((item) => - HostToolRequest.load(item as Record, context), + return data.map((item, index) => + HostToolRequest.load( + item as Record, + context.atIndex(index), + ), ); } diff --git a/runtime/typescript/packages/core/src/model/pipeline/turn-options.ts b/runtime/typescript/packages/core/src/model/pipeline/turn-options.ts index e6ef5b459..0898dfd21 100644 --- a/runtime/typescript/packages/core/src/model/pipeline/turn-options.ts +++ b/runtime/typescript/packages/core/src/model/pipeline/turn-options.ts @@ -46,6 +46,7 @@ export class TurnOptions { data: Record, context?: LoadContext, ): TurnOptions { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -76,7 +77,7 @@ export class TurnOptions { if (data["compaction"] !== undefined && data["compaction"] !== null) { instance.compaction = CompactionConfig.load( data["compaction"] as Record, - context, + context.at("compaction"), ); } diff --git a/runtime/typescript/packages/core/src/model/streaming/stream-options.ts b/runtime/typescript/packages/core/src/model/streaming/stream-options.ts index e1763b43d..42be56668 100644 --- a/runtime/typescript/packages/core/src/model/streaming/stream-options.ts +++ b/runtime/typescript/packages/core/src/model/streaming/stream-options.ts @@ -21,6 +21,7 @@ export class StreamOptions { data: Record, context?: LoadContext, ): StreamOptions { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/template/format-config.ts b/runtime/typescript/packages/core/src/model/template/format-config.ts index 3a8050bb9..7b63625b6 100644 --- a/runtime/typescript/packages/core/src/model/template/format-config.ts +++ b/runtime/typescript/packages/core/src/model/template/format-config.ts @@ -27,6 +27,7 @@ export class FormatConfig { data: Record, context?: LoadContext, ): FormatConfig { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/template/parser-config.ts b/runtime/typescript/packages/core/src/model/template/parser-config.ts index b676e890c..bf7a147e5 100644 --- a/runtime/typescript/packages/core/src/model/template/parser-config.ts +++ b/runtime/typescript/packages/core/src/model/template/parser-config.ts @@ -23,6 +23,7 @@ export class ParserConfig { data: Record, context?: LoadContext, ): ParserConfig { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/template/template.ts b/runtime/typescript/packages/core/src/model/template/template.ts index e54d16bec..964904752 100644 --- a/runtime/typescript/packages/core/src/model/template/template.ts +++ b/runtime/typescript/packages/core/src/model/template/template.ts @@ -24,22 +24,29 @@ export class Template { //#region Load Methods static load(data: Record, context?: LoadContext): Template { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["format"] === undefined || data["format"] === null) { + throw new Error(`${context.at("format").path}: missing required field`); + } + if (data["parser"] === undefined || data["parser"] === null) { + throw new Error(`${context.at("parser").path}: missing required field`); + } const instance = new Template(); if (data["format"] !== undefined && data["format"] !== null) { instance.format = FormatConfig.load( data["format"] as Record, - context, + context.at("format"), ); } if (data["parser"] !== undefined && data["parser"] !== null) { instance.parser = ParserConfig.load( data["parser"] as Record, - context, + context.at("parser"), ); } diff --git a/runtime/typescript/packages/core/src/model/tools/binding.ts b/runtime/typescript/packages/core/src/model/tools/binding.ts index 378f5b94f..62414b638 100644 --- a/runtime/typescript/packages/core/src/model/tools/binding.ts +++ b/runtime/typescript/packages/core/src/model/tools/binding.ts @@ -18,6 +18,7 @@ export class Binding { //#region Load Methods static load(data: Record, context?: LoadContext): Binding { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/tools/mcp-approval-mode.ts b/runtime/typescript/packages/core/src/model/tools/mcp-approval-mode.ts index 54169e0ee..887a032ac 100644 --- a/runtime/typescript/packages/core/src/model/tools/mcp-approval-mode.ts +++ b/runtime/typescript/packages/core/src/model/tools/mcp-approval-mode.ts @@ -10,8 +10,8 @@ export class McpApprovalMode { static readonly shorthandProperty: string | undefined = "kind"; kind: mcpApprovalModeKind = "always"; - alwaysRequireApprovalTools?: string[] = []; - neverRequireApprovalTools?: string[] = []; + alwaysRequireApprovalTools?: string[]; + neverRequireApprovalTools?: string[]; constructor(init?: Partial) { this.kind = init?.kind ?? "always"; @@ -29,6 +29,7 @@ export class McpApprovalMode { data: Record, context?: LoadContext, ): McpApprovalMode { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/tools/tool-context.ts b/runtime/typescript/packages/core/src/model/tools/tool-context.ts index a8b63fe1b..ff7a59577 100644 --- a/runtime/typescript/packages/core/src/model/tools/tool-context.ts +++ b/runtime/typescript/packages/core/src/model/tools/tool-context.ts @@ -24,6 +24,7 @@ export class ToolContext { data: Record, context?: LoadContext, ): ToolContext { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -33,7 +34,7 @@ export class ToolContext { if (data["messages"] !== undefined && data["messages"] !== null) { instance.messages = ToolContext.loadMessages( data["messages"] as unknown[], - context, + context.at("messages"), ); } if (data["metadata"] !== undefined && data["metadata"] !== null) { @@ -50,20 +51,31 @@ export class ToolContext { data: Record[] | unknown[], context?: LoadContext, ): Message[] { + context ??= new LoadContext({ path: "messages" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Message[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Message.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, role: v }); + result.push(Message.load({ name: k, role: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Message.load(item as Record, context), + return data.map((item, index) => + Message.load(item as Record, context.atIndex(index)), ); } diff --git a/runtime/typescript/packages/core/src/model/tools/tool-dispatch-result.ts b/runtime/typescript/packages/core/src/model/tools/tool-dispatch-result.ts index 3250bcdf9..456582772 100644 --- a/runtime/typescript/packages/core/src/model/tools/tool-dispatch-result.ts +++ b/runtime/typescript/packages/core/src/model/tools/tool-dispatch-result.ts @@ -26,10 +26,14 @@ export class ToolDispatchResult { data: Record, context?: LoadContext, ): ToolDispatchResult { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["result"] === undefined || data["result"] === null) { + throw new Error(`${context.at("result").path}: missing required field`); + } const instance = new ToolDispatchResult(); if (data["toolCallId"] !== undefined && data["toolCallId"] !== null) { @@ -41,7 +45,7 @@ export class ToolDispatchResult { if (data["result"] !== undefined && data["result"] !== null) { instance.result = ToolResult.load( data["result"] as Record, - context, + context.at("result"), ); } diff --git a/runtime/typescript/packages/core/src/model/tools/tool.ts b/runtime/typescript/packages/core/src/model/tools/tool.ts index e00f4bc33..4b89d1dd7 100644 --- a/runtime/typescript/packages/core/src/model/tools/tool.ts +++ b/runtime/typescript/packages/core/src/model/tools/tool.ts @@ -22,14 +22,13 @@ export abstract class Tool { if (init?.description !== undefined) { this.description = init.description; } - if (init?.bindings !== undefined) { - this.bindings = init.bindings; - } + this.bindings = init?.bindings ?? []; } //#region Load Methods static load(data: Record, context?: LoadContext): Tool { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -49,7 +48,7 @@ export abstract class Tool { if (data["bindings"] !== undefined && data["bindings"] !== null) { instance.bindings = Tool.loadBindings( data["bindings"] as unknown[], - context, + context.at("bindings"), ); } @@ -65,7 +64,7 @@ export abstract class Tool { ): Tool { const discriminatorValue = data["kind"]; if (discriminatorValue !== undefined && discriminatorValue !== null) { - const discriminator = String(discriminatorValue).toLowerCase(); + const discriminator = String(discriminatorValue); switch (discriminator) { case "function": return FunctionTool.load(data, context); @@ -86,20 +85,31 @@ export abstract class Tool { data: Record[] | unknown[], context?: LoadContext, ): Binding[] { + context ??= new LoadContext({ path: "bindings" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Binding[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Binding.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, input: v }); + result.push(Binding.load({ name: k, input: v }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Binding.load(item as Record, context), + return data.map((item, index) => + Binding.load(item as Record, context.atIndex(index)), ); } @@ -111,37 +121,44 @@ export abstract class Tool { context = new SaveContext(); } + const serialized = items.map( + (item) => ({ ...item.save(context) }) as Record, + ); + for (const itemData of serialized) { + if (itemData["name"] === "") delete itemData["name"]; + } + if (context.collectionFormat === "array") { - return items.map((item) => item.save(context)); + return serialized; + } + + const names = new Set(); + for (const itemData of serialized) { + const name = itemData["name"]; + if (typeof name !== "string" || name.length === 0 || names.has(name)) + return serialized; + names.add(name); } // Object format: use name as key const result: Record = {}; - for (const item of items) { - const itemData = item.save(context) as Record; - const name = itemData["name"] as string | undefined; + for (let index = 0; index < items.length; index++) { + const item = items[index]; + const itemData = serialized[index]; + const name = itemData["name"] as string; delete itemData["name"]; - if (name) { - // Check if we can use shorthand (only primary property set) - const shorthand = (item.constructor as typeof Binding) - .shorthandProperty; - if ( - context.useShorthand && - shorthand && - Object.keys(itemData).length === 1 && - shorthand in itemData - ) { - result[name] = itemData[shorthand]; - continue; - } - result[name] = itemData; - } else { - // No name, fall back to array format for this item - if (!result["_unnamed"]) { - result["_unnamed"] = []; - } - (result["_unnamed"] as unknown[]).push(itemData); + // Check if we can use shorthand (only primary property set) + const shorthand = (item.constructor as typeof Binding).shorthandProperty; + if ( + context.useShorthand && + shorthand && + Object.keys(itemData).length === 1 && + shorthand in itemData + ) { + result[name] = itemData[shorthand]; + continue; } + result[name] = itemData; } return result; } @@ -223,6 +240,7 @@ export class FunctionTool extends Tool { data: Record, context?: LoadContext, ): FunctionTool { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -235,7 +253,7 @@ export class FunctionTool extends Tool { if (data["parameters"] !== undefined && data["parameters"] !== null) { instance.parameters = FunctionTool.loadParameters( data["parameters"] as unknown[], - context, + context.at("parameters"), ); } if (data["strict"] !== undefined && data["strict"] !== null) { @@ -252,20 +270,43 @@ export class FunctionTool extends Tool { data: Record[] | unknown[], context?: LoadContext, ): Property[] { + context ??= new LoadContext({ path: "parameters" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: Property[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + Property.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, kind: v }); + let shorthand: Record; + if (typeof v === "number" && Number.isInteger(v)) { + shorthand = { kind: "integer", default: v }; + } else if (typeof v === "number") { + shorthand = { kind: "float", default: v }; + } else if (typeof v === "string") { + shorthand = { kind: "string", default: v }; + } else if (typeof v === "boolean") { + shorthand = { kind: "boolean", default: v }; + } else { + shorthand = { default: v }; + } + result.push(Property.load({ name: k, ...shorthand }, context.at(k))); } } - data = result; + return result; } - return data.map((item) => - Property.load(item as Record, context), + return data.map((item, index) => + Property.load(item as Record, context.atIndex(index)), ); } @@ -277,37 +318,44 @@ export class FunctionTool extends Tool { context = new SaveContext(); } + const serialized = items.map( + (item) => ({ ...item.save(context) }) as Record, + ); + for (const itemData of serialized) { + if (itemData["name"] === "") delete itemData["name"]; + } + if (context.collectionFormat === "array") { - return items.map((item) => item.save(context)); + return serialized; + } + + const names = new Set(); + for (const itemData of serialized) { + const name = itemData["name"]; + if (typeof name !== "string" || name.length === 0 || names.has(name)) + return serialized; + names.add(name); } // Object format: use name as key const result: Record = {}; - for (const item of items) { - const itemData = item.save(context) as Record; - const name = itemData["name"] as string | undefined; + for (let index = 0; index < items.length; index++) { + const item = items[index]; + const itemData = serialized[index]; + const name = itemData["name"] as string; delete itemData["name"]; - if (name) { - // Check if we can use shorthand (only primary property set) - const shorthand = (item.constructor as typeof Property) - .shorthandProperty; - if ( - context.useShorthand && - shorthand && - Object.keys(itemData).length === 1 && - shorthand in itemData - ) { - result[name] = itemData[shorthand]; - continue; - } - result[name] = itemData; - } else { - // No name, fall back to array format for this item - if (!result["_unnamed"]) { - result["_unnamed"] = []; - } - (result["_unnamed"] as unknown[]).push(itemData); + // Check if we can use shorthand (only primary property set) + const shorthand = (item.constructor as typeof Property).shorthandProperty; + if ( + context.useShorthand && + shorthand && + Object.keys(itemData).length === 1 && + shorthand in itemData + ) { + result[name] = itemData[shorthand]; + continue; } + result[name] = itemData; } return result; } @@ -386,10 +434,20 @@ export class CustomTool extends Tool { data: Record, context?: LoadContext, ): CustomTool { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if ( + typeof data["kind"] === "string" && + data["kind"] !== "" && + (data["connection"] === undefined || data["connection"] === null) + ) { + throw new Error( + `${context.at("connection").path}: missing required field`, + ); + } const instance = new CustomTool(); if (data["kind"] !== undefined && data["kind"] !== null) { @@ -398,7 +456,7 @@ export class CustomTool extends Tool { if (data["connection"] !== undefined && data["connection"] !== null) { instance.connection = Connection.load( data["connection"] as Record, - context, + context.at("connection"), ); } if (data["options"] !== undefined && data["options"] !== null) { @@ -467,8 +525,8 @@ export class McpTool extends Tool { connection!: Connection; serverName: string = ""; serverDescription?: string | undefined; - approvalMode!: McpApprovalMode; - allowedTools?: string[] = []; + approvalMode?: McpApprovalMode | undefined; + allowedTools?: string[]; constructor(init?: Partial) { super(init); @@ -491,10 +549,16 @@ export class McpTool extends Tool { //#region Load Methods static load(data: Record, context?: LoadContext): McpTool { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["connection"] === undefined || data["connection"] === null) { + throw new Error( + `${context.at("connection").path}: missing required field`, + ); + } const instance = new McpTool(); if (data["kind"] !== undefined && data["kind"] !== null) { @@ -503,7 +567,7 @@ export class McpTool extends Tool { if (data["connection"] !== undefined && data["connection"] !== null) { instance.connection = Connection.load( data["connection"] as Record, - context, + context.at("connection"), ); } if (data["serverName"] !== undefined && data["serverName"] !== null) { @@ -518,7 +582,7 @@ export class McpTool extends Tool { if (data["approvalMode"] !== undefined && data["approvalMode"] !== null) { instance.approvalMode = McpApprovalMode.load( data["approvalMode"] as Record, - context, + context.at("approvalMode"), ); } if (data["allowedTools"] !== undefined && data["allowedTools"] !== null) { @@ -613,10 +677,16 @@ export class OpenApiTool extends Tool { data: Record, context?: LoadContext, ): OpenApiTool { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["connection"] === undefined || data["connection"] === null) { + throw new Error( + `${context.at("connection").path}: missing required field`, + ); + } const instance = new OpenApiTool(); if (data["kind"] !== undefined && data["kind"] !== null) { @@ -625,7 +695,7 @@ export class OpenApiTool extends Tool { if (data["connection"] !== undefined && data["connection"] !== null) { instance.connection = Connection.load( data["connection"] as Record, - context, + context.at("connection"), ); } if (data["specification"] !== undefined && data["specification"] !== null) { @@ -707,6 +777,7 @@ export class PromptyTool extends Tool { data: Record, context?: LoadContext, ): PromptyTool { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/tracing/trace-file.ts b/runtime/typescript/packages/core/src/model/tracing/trace-file.ts index eabf02a18..de6f3ae3f 100644 --- a/runtime/typescript/packages/core/src/model/tracing/trace-file.ts +++ b/runtime/typescript/packages/core/src/model/tracing/trace-file.ts @@ -23,10 +23,14 @@ export class TraceFile { //#region Load Methods static load(data: Record, context?: LoadContext): TraceFile { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["trace"] === undefined || data["trace"] === null) { + throw new Error(`${context.at("trace").path}: missing required field`); + } const instance = new TraceFile(); if (data["runtime"] !== undefined && data["runtime"] !== null) { @@ -38,7 +42,7 @@ export class TraceFile { if (data["trace"] !== undefined && data["trace"] !== null) { instance.trace = TraceSpan.load( data["trace"] as Record, - context, + context.at("trace"), ); } diff --git a/runtime/typescript/packages/core/src/model/tracing/trace-span.ts b/runtime/typescript/packages/core/src/model/tracing/trace-span.ts index 5fd017bec..8b8a317e0 100644 --- a/runtime/typescript/packages/core/src/model/tracing/trace-span.ts +++ b/runtime/typescript/packages/core/src/model/tracing/trace-span.ts @@ -17,7 +17,7 @@ export class TraceSpan { error?: string | undefined; __usage?: TokenUsage | undefined; attributes?: Record | undefined; - __frames?: unknown[] = []; + __frames?: unknown[]; constructor(init?: Partial) { this.name = init?.name ?? ""; @@ -50,10 +50,14 @@ export class TraceSpan { //#region Load Methods static load(data: Record, context?: LoadContext): TraceSpan { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["__time"] === undefined || data["__time"] === null) { + throw new Error(`${context.at("__time").path}: missing required field`); + } const instance = new TraceSpan(); if (data["name"] !== undefined && data["name"] !== null) { @@ -62,7 +66,7 @@ export class TraceSpan { if (data["__time"] !== undefined && data["__time"] !== null) { instance.__time = TraceTime.load( data["__time"] as Record, - context, + context.at("__time"), ); } if (data["signature"] !== undefined && data["signature"] !== null) { @@ -80,7 +84,7 @@ export class TraceSpan { if (data["__usage"] !== undefined && data["__usage"] !== null) { instance.__usage = TokenUsage.load( data["__usage"] as Record, - context, + context.at("__usage"), ); } if (data["attributes"] !== undefined && data["attributes"] !== null) { diff --git a/runtime/typescript/packages/core/src/model/tracing/trace-time.ts b/runtime/typescript/packages/core/src/model/tracing/trace-time.ts index be24b71e6..092cdb44c 100644 --- a/runtime/typescript/packages/core/src/model/tracing/trace-time.ts +++ b/runtime/typescript/packages/core/src/model/tracing/trace-time.ts @@ -20,6 +20,7 @@ export class TraceTime { //#region Load Methods static load(data: Record, context?: LoadContext): TraceTime { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/wire/anthropic-image-block.ts b/runtime/typescript/packages/core/src/model/wire/anthropic-image-block.ts index ec2df219f..3d1af06d6 100644 --- a/runtime/typescript/packages/core/src/model/wire/anthropic-image-block.ts +++ b/runtime/typescript/packages/core/src/model/wire/anthropic-image-block.ts @@ -24,10 +24,14 @@ export class AnthropicImageBlock { data: Record, context?: LoadContext, ): AnthropicImageBlock { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["source"] === undefined || data["source"] === null) { + throw new Error(`${context.at("source").path}: missing required field`); + } const instance = new AnthropicImageBlock(); if (data["type"] !== undefined && data["type"] !== null) { @@ -36,7 +40,7 @@ export class AnthropicImageBlock { if (data["source"] !== undefined && data["source"] !== null) { instance.source = AnthropicImageSource.load( data["source"] as Record, - context, + context.at("source"), ); } diff --git a/runtime/typescript/packages/core/src/model/wire/anthropic-image-source.ts b/runtime/typescript/packages/core/src/model/wire/anthropic-image-source.ts index 319c38c94..a37b3178c 100644 --- a/runtime/typescript/packages/core/src/model/wire/anthropic-image-source.ts +++ b/runtime/typescript/packages/core/src/model/wire/anthropic-image-source.ts @@ -23,6 +23,7 @@ export class AnthropicImageSource { data: Record, context?: LoadContext, ): AnthropicImageSource { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/wire/anthropic-messages-request.ts b/runtime/typescript/packages/core/src/model/wire/anthropic-messages-request.ts index 8dde01a69..8f825cd5b 100644 --- a/runtime/typescript/packages/core/src/model/wire/anthropic-messages-request.ts +++ b/runtime/typescript/packages/core/src/model/wire/anthropic-messages-request.ts @@ -16,8 +16,8 @@ export class AnthropicMessagesRequest { temperature?: number | undefined; top_p?: number | undefined; top_k?: number | undefined; - stop_sequences?: string[] = []; - tools?: AnthropicToolDefinition[] = []; + stop_sequences?: string[]; + tools?: AnthropicToolDefinition[]; constructor(init?: Partial) { this.model = init?.model ?? ""; @@ -49,6 +49,7 @@ export class AnthropicMessagesRequest { data: Record, context?: LoadContext, ): AnthropicMessagesRequest { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } @@ -61,7 +62,7 @@ export class AnthropicMessagesRequest { if (data["messages"] !== undefined && data["messages"] !== null) { instance.messages = AnthropicMessagesRequest.loadMessages( data["messages"] as unknown[], - context, + context.at("messages"), ); } if (data["max_tokens"] !== undefined && data["max_tokens"] !== null) { @@ -90,7 +91,7 @@ export class AnthropicMessagesRequest { if (data["tools"] !== undefined && data["tools"] !== null) { instance.tools = AnthropicMessagesRequest.loadTools( data["tools"] as unknown[], - context, + context.at("tools"), ); } @@ -104,20 +105,36 @@ export class AnthropicMessagesRequest { data: Record[] | unknown[], context?: LoadContext, ): AnthropicWireMessage[] { + context ??= new LoadContext({ path: "messages" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: AnthropicWireMessage[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + AnthropicWireMessage.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, role: v }); + result.push( + AnthropicWireMessage.load({ name: k, role: v }, context.at(k)), + ); } } - data = result; + return result; } - return data.map((item) => - AnthropicWireMessage.load(item as Record, context), + return data.map((item, index) => + AnthropicWireMessage.load( + item as Record, + context.atIndex(index), + ), ); } @@ -137,20 +154,39 @@ export class AnthropicMessagesRequest { data: Record[] | unknown[], context?: LoadContext, ): AnthropicToolDefinition[] { + context ??= new LoadContext({ path: "tools" }); if (!Array.isArray(data)) { - // Convert dict/object format to array format - const result: Record[] = []; + const result: AnthropicToolDefinition[] = []; for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) { + throw new TypeError( + context.at(k).path + + ": invalid named collection entry category array", + ); + } if (typeof v === "object" && v !== null && !Array.isArray(v)) { - result.push({ name: k, ...(v as Record) }); + result.push( + AnthropicToolDefinition.load( + { name: k, ...(v as Record) }, + context.at(k), + ), + ); } else { - result.push({ name: k, description: v }); + result.push( + AnthropicToolDefinition.load( + { name: k, description: v }, + context.at(k), + ), + ); } } - data = result; + return result; } - return data.map((item) => - AnthropicToolDefinition.load(item as Record, context), + return data.map((item, index) => + AnthropicToolDefinition.load( + item as Record, + context.atIndex(index), + ), ); } @@ -162,39 +198,8 @@ export class AnthropicMessagesRequest { context = new SaveContext(); } - if (context.collectionFormat === "array") { - return items.map((item) => item.save(context)); - } - - // Object format: use name as key - const result: Record = {}; - for (const item of items) { - const itemData = item.save(context) as Record; - const name = itemData["name"] as string | undefined; - delete itemData["name"]; - if (name) { - // Check if we can use shorthand (only primary property set) - const shorthand = (item.constructor as typeof AnthropicToolDefinition) - .shorthandProperty; - if ( - context.useShorthand && - shorthand && - Object.keys(itemData).length === 1 && - shorthand in itemData - ) { - result[name] = itemData[shorthand]; - continue; - } - result[name] = itemData; - } else { - // No name, fall back to array format for this item - if (!result["_unnamed"]) { - result["_unnamed"] = []; - } - (result["_unnamed"] as unknown[]).push(itemData); - } - } - return result; + // This type doesn't have a 'name' property, so always use array format + return items.map((item) => item.save(context)); } //#endregion diff --git a/runtime/typescript/packages/core/src/model/wire/anthropic-messages-response.ts b/runtime/typescript/packages/core/src/model/wire/anthropic-messages-response.ts index 50fd78b8b..3d6c526ae 100644 --- a/runtime/typescript/packages/core/src/model/wire/anthropic-messages-response.ts +++ b/runtime/typescript/packages/core/src/model/wire/anthropic-messages-response.ts @@ -34,10 +34,14 @@ export class AnthropicMessagesResponse { data: Record, context?: LoadContext, ): AnthropicMessagesResponse { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } + if (data["usage"] === undefined || data["usage"] === null) { + throw new Error(`${context.at("usage").path}: missing required field`); + } const instance = new AnthropicMessagesResponse(); if (data["id"] !== undefined && data["id"] !== null) { @@ -61,7 +65,7 @@ export class AnthropicMessagesResponse { if (data["usage"] !== undefined && data["usage"] !== null) { instance.usage = AnthropicUsage.load( data["usage"] as Record, - context, + context.at("usage"), ); } diff --git a/runtime/typescript/packages/core/src/model/wire/anthropic-text-block.ts b/runtime/typescript/packages/core/src/model/wire/anthropic-text-block.ts index e441b5c05..fc9cdcb1e 100644 --- a/runtime/typescript/packages/core/src/model/wire/anthropic-text-block.ts +++ b/runtime/typescript/packages/core/src/model/wire/anthropic-text-block.ts @@ -21,6 +21,7 @@ export class AnthropicTextBlock { data: Record, context?: LoadContext, ): AnthropicTextBlock { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/wire/anthropic-tool-definition.ts b/runtime/typescript/packages/core/src/model/wire/anthropic-tool-definition.ts index 07f9017df..aae288309 100644 --- a/runtime/typescript/packages/core/src/model/wire/anthropic-tool-definition.ts +++ b/runtime/typescript/packages/core/src/model/wire/anthropic-tool-definition.ts @@ -25,6 +25,7 @@ export class AnthropicToolDefinition { data: Record, context?: LoadContext, ): AnthropicToolDefinition { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/wire/anthropic-tool-result-block.ts b/runtime/typescript/packages/core/src/model/wire/anthropic-tool-result-block.ts index b51ccced3..b26313299 100644 --- a/runtime/typescript/packages/core/src/model/wire/anthropic-tool-result-block.ts +++ b/runtime/typescript/packages/core/src/model/wire/anthropic-tool-result-block.ts @@ -23,6 +23,7 @@ export class AnthropicToolResultBlock { data: Record, context?: LoadContext, ): AnthropicToolResultBlock { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/wire/anthropic-tool-use-block.ts b/runtime/typescript/packages/core/src/model/wire/anthropic-tool-use-block.ts index 1a21976ab..2936d5d41 100644 --- a/runtime/typescript/packages/core/src/model/wire/anthropic-tool-use-block.ts +++ b/runtime/typescript/packages/core/src/model/wire/anthropic-tool-use-block.ts @@ -25,6 +25,7 @@ export class AnthropicToolUseBlock { data: Record, context?: LoadContext, ): AnthropicToolUseBlock { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/wire/anthropic-usage.ts b/runtime/typescript/packages/core/src/model/wire/anthropic-usage.ts index 532de4a83..332fb7c02 100644 --- a/runtime/typescript/packages/core/src/model/wire/anthropic-usage.ts +++ b/runtime/typescript/packages/core/src/model/wire/anthropic-usage.ts @@ -21,6 +21,7 @@ export class AnthropicUsage { data: Record, context?: LoadContext, ): AnthropicUsage { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/src/model/wire/anthropic-wire-message.ts b/runtime/typescript/packages/core/src/model/wire/anthropic-wire-message.ts index 6415c2cf8..8dbd22776 100644 --- a/runtime/typescript/packages/core/src/model/wire/anthropic-wire-message.ts +++ b/runtime/typescript/packages/core/src/model/wire/anthropic-wire-message.ts @@ -21,6 +21,7 @@ export class AnthropicWireMessage { data: Record, context?: LoadContext, ): AnthropicWireMessage { + context ??= new LoadContext(); if (context) { data = context.processInput(data) as Record; } diff --git a/runtime/typescript/packages/core/tests/connection-roundtrip-vectors.test.ts b/runtime/typescript/packages/core/tests/connection-roundtrip-vectors.test.ts new file mode 100644 index 000000000..af1f844d4 --- /dev/null +++ b/runtime/typescript/packages/core/tests/connection-roundtrip-vectors.test.ts @@ -0,0 +1,51 @@ +/** + * Canonical forward-compatibility tests for open Connection discriminators. + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { Connection, ReferenceConnection } from "../src/index.js"; + +interface ConnectionRoundtripVector { + name: string; + operation: "load-save-reload"; + input: Record; + expected: Record; +} + +const vectorsPath = resolve( + import.meta.dirname, + "../../../../../spec/vectors/model/connection_roundtrip_vectors.json", +); +const vectors = ( + JSON.parse(readFileSync(vectorsPath, "utf8")) as { + vectors: ConnectionRoundtripVector[]; + } +).vectors; + +describe("Connection roundtrip vectors", () => { + it.each(vectors)( + "$name preserves the exact discriminator and payload", + (vector) => { + expect(vector.operation).toBe("load-save-reload"); + + const loaded = Connection.load(vector.input); + if (vector.expected.kind === "reference") { + expect(loaded).toBeInstanceOf(ReferenceConnection); + } else { + expect(loaded).not.toBeInstanceOf(ReferenceConnection); + } + + const saved = loaded.save(); + expect(saved.kind).toBe(vector.expected.kind); + expect(saved).toEqual(vector.expected); + + const reloaded = Connection.load(saved); + const resaved = reloaded.save(); + expect(resaved.kind).toBe(vector.expected.kind); + expect(resaved).toEqual(vector.expected); + }, + ); +}); diff --git a/runtime/typescript/packages/core/tests/content-part-discriminator-vectors.test.ts b/runtime/typescript/packages/core/tests/content-part-discriminator-vectors.test.ts new file mode 100644 index 000000000..754f88279 --- /dev/null +++ b/runtime/typescript/packages/core/tests/content-part-discriminator-vectors.test.ts @@ -0,0 +1,51 @@ +/** + * Canonical strict-discriminator tests for the closed ContentPart hierarchy. + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + ContentPart, + TextPart, +} from "../src/model/conversation/content-part.js"; + +interface ContentPartDiscriminatorVector { + name: string; + operation: "load" | "load-error"; + input: Record; + expected: Record; +} + +const vectorsPath = resolve( + import.meta.dirname, + "../../../../../spec/vectors/model/content_part_discriminator_vectors.json", +); +const vectors = ( + JSON.parse(readFileSync(vectorsPath, "utf8")) as { + vectors: ContentPartDiscriminatorVector[]; + } +).vectors; + +describe("ContentPart discriminator vectors", () => { + it.each(vectors)("$name enforces closed, case-sensitive kinds", (vector) => { + if (vector.operation === "load") { + const loaded = ContentPart.load(vector.input); + expect(loaded).toBeInstanceOf(TextPart); + expect(loaded.save()).toEqual(vector.expected); + return; + } + + let diagnostic = ""; + try { + ContentPart.load(vector.input); + } catch (error) { + diagnostic = String(error); + } + + expect(diagnostic).not.toBe(""); + expect(diagnostic).toContain(String(vector.expected.discriminator)); + expect(diagnostic).toContain(String(vector.expected.value)); + }); +}); diff --git a/runtime/typescript/packages/core/tests/fixtures/structured.prompty b/runtime/typescript/packages/core/tests/fixtures/structured.prompty index 2cab5ceb2..4081c23d0 100644 --- a/runtime/typescript/packages/core/tests/fixtures/structured.prompty +++ b/runtime/typescript/packages/core/tests/fixtures/structured.prompty @@ -21,6 +21,8 @@ outputs: kind: array description: Key points about the topic required: true + items: + kind: string - name: confidence kind: number description: Confidence score 0-1 diff --git a/runtime/typescript/packages/core/tests/model/agent/guardrail-result.test.ts b/runtime/typescript/packages/core/tests/model/agent/guardrail-result.test.ts index 8c7de005a..2fa73c09a 100644 --- a/runtime/typescript/packages/core/tests/model/agent/guardrail-result.test.ts +++ b/runtime/typescript/packages/core/tests/model/agent/guardrail-result.test.ts @@ -78,7 +78,9 @@ describe("GuardrailResult", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "allowed": true,\n "reason": "Content is safe"\n}`, + ) as Record; const instance = GuardrailResult.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/agent/prompty.test.ts b/runtime/typescript/packages/core/tests/model/agent/prompty.test.ts index 401cb72e0..5ac7f1268 100644 --- a/runtime/typescript/packages/core/tests/model/agent/prompty.test.ts +++ b/runtime/typescript/packages/core/tests/model/agent/prompty.test.ts @@ -214,7 +214,7 @@ describe("Prompty", () => { describe("YAML serialization", () => { it("should load from YAML - example 1", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.name).toEqual("basic-prompt"); @@ -228,7 +228,7 @@ describe("Prompty", () => { }); it("should round-trip YAML - example 1", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); const output = instance.toYaml(); const reloaded = Prompty.fromYaml(output); @@ -238,7 +238,7 @@ describe("Prompty", () => { expect(reloaded.instructions).toEqual(instance.instructions); }); it("should load from YAML - example 2", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.name).toEqual("basic-prompt"); @@ -252,7 +252,7 @@ describe("Prompty", () => { }); it("should round-trip YAML - example 2", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); const output = instance.toYaml(); const reloaded = Prompty.fromYaml(output); @@ -262,7 +262,7 @@ describe("Prompty", () => { expect(reloaded.instructions).toEqual(instance.instructions); }); it("should load from YAML - example 3", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.name).toEqual("basic-prompt"); @@ -276,7 +276,7 @@ describe("Prompty", () => { }); it("should round-trip YAML - example 3", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); const output = instance.toYaml(); const reloaded = Prompty.fromYaml(output); @@ -286,7 +286,7 @@ describe("Prompty", () => { expect(reloaded.instructions).toEqual(instance.instructions); }); it("should load from YAML - example 4", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.name).toEqual("basic-prompt"); @@ -300,7 +300,7 @@ describe("Prompty", () => { }); it("should round-trip YAML - example 4", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n firstName:\n kind: string\n default: Jane\n lastName:\n kind: string\n default: Doe\n question:\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); const output = instance.toYaml(); const reloaded = Prompty.fromYaml(output); @@ -310,7 +310,7 @@ describe("Prompty", () => { expect(reloaded.instructions).toEqual(instance.instructions); }); it("should load from YAML - example 5", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.name).toEqual("basic-prompt"); @@ -324,7 +324,7 @@ describe("Prompty", () => { }); it("should round-trip YAML - example 5", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); const output = instance.toYaml(); const reloaded = Prompty.fromYaml(output); @@ -334,7 +334,7 @@ describe("Prompty", () => { expect(reloaded.instructions).toEqual(instance.instructions); }); it("should load from YAML - example 6", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.name).toEqual("basic-prompt"); @@ -348,7 +348,7 @@ describe("Prompty", () => { }); it("should round-trip YAML - example 6", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n answer:\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); const output = instance.toYaml(); const reloaded = Prompty.fromYaml(output); @@ -358,7 +358,7 @@ describe("Prompty", () => { expect(reloaded.instructions).toEqual(instance.instructions); }); it("should load from YAML - example 7", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.name).toEqual("basic-prompt"); @@ -372,7 +372,7 @@ describe("Prompty", () => { }); it("should round-trip YAML - example 7", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n - name: getCurrentWeather\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); const output = instance.toYaml(); const reloaded = Prompty.fromYaml(output); @@ -382,7 +382,7 @@ describe("Prompty", () => { expect(reloaded.instructions).toEqual(instance.instructions); }); it("should load from YAML - example 8", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.name).toEqual("basic-prompt"); @@ -396,7 +396,7 @@ describe("Prompty", () => { }); it("should round-trip YAML - example 8", () => { - const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\n\n You are an AI assistant who helps people find information.\n\n As the assistant, you answer questions briefly, succinctly,\n\n and in a personable manner using markdown and even add some\\ \n\n personal flair with appropriate emojis.\n\n\n # Customer\n\n You are helping {{firstName}} {{lastName}} to find answers to\\ \n\n their questions. Use their name to address them in your responses.\n\n user:\n\n {{question}}"\n`; + const yaml = `name: basic-prompt\ndisplayName: Basic Prompt\ndescription: A basic prompt that uses the GPT-3 chat API to answer questions\nmetadata:\n authors:\n - sethjuarez\n - jietong\n tags:\n - example\n - prompt\ninputs:\n - name: firstName\n kind: string\n default: Jane\n - name: lastName\n kind: string\n default: Doe\n - name: question\n kind: string\n default: What is the meaning of life?\noutputs:\n - name: answer\n kind: string\n description: The answer to the user's question.\nmodel:\n id: gpt-35-turbo\n connection:\n kind: key\n endpoint: "https://{your-custom-endpoint}.openai.azure.com/"\n apiKey: "{your-api-key}"\ntools:\n getCurrentWeather:\n kind: function\n description: Get the current weather in a given location\n parameters:\n location:\n kind: string\n description: The city and state, e.g. San Francisco, CA\n unit:\n kind: string\n description: The unit of temperature, e.g. Celsius or Fahrenheit\ntemplate:\n format: mustache\n parser: prompty\ninstructions: "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n`; const instance = Prompty.fromYaml(yaml); const output = instance.toYaml(); const reloaded = Prompty.fromYaml(output); @@ -409,7 +409,9 @@ describe("Prompty", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "name": "basic-prompt",\n "displayName": "Basic Prompt",\n "description": "A basic prompt that uses the GPT-3 chat API to answer questions",\n "metadata": {\n "authors": [\n "sethjuarez",\n "jietong"\n ],\n "tags": [\n "example",\n "prompt"\n ]\n },\n "inputs": {\n "firstName": {\n "kind": "string",\n "default": "Jane"\n },\n "lastName": {\n "kind": "string",\n "default": "Doe"\n },\n "question": {\n "kind": "string",\n "default": "What is the meaning of life?"\n }\n },\n "outputs": {\n "answer": {\n "kind": "string",\n "description": "The answer to the user's question."\n }\n },\n "model": {\n "id": "gpt-35-turbo",\n "connection": {\n "kind": "key",\n "endpoint": "https://{your-custom-endpoint}.openai.azure.com/",\n "apiKey": "{your-api-key}"\n }\n },\n "tools": [\n {\n "name": "getCurrentWeather",\n "kind": "function",\n "description": "Get the current weather in a given location",\n "parameters": {\n "location": {\n "kind": "string",\n "description": "The city and state, e.g. San Francisco, CA"\n },\n "unit": {\n "kind": "string",\n "description": "The unit of temperature, e.g. Celsius or Fahrenheit"\n }\n }\n }\n ],\n "template": {\n "format": "mustache",\n "parser": "prompty"\n },\n "instructions": "system:\\nYou are an AI assistant who helps people find information.\\nAs the assistant, you answer questions briefly, succinctly,\\nand in a personable manner using markdown and even add some \\npersonal flair with appropriate emojis.\\n\\n# Customer\\nYou are helping {{firstName}} {{lastName}} to find answers to \\ntheir questions. Use their name to address them in your responses.\\nuser:\\n{{question}}"\n}`, + ) as Record; const instance = Prompty.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/connection/anonymous-connection.test.ts b/runtime/typescript/packages/core/tests/model/connection/anonymous-connection.test.ts index 57c5bcda2..8c06dbca9 100644 --- a/runtime/typescript/packages/core/tests/model/connection/anonymous-connection.test.ts +++ b/runtime/typescript/packages/core/tests/model/connection/anonymous-connection.test.ts @@ -61,7 +61,9 @@ describe("AnonymousConnection", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "kind": "anonymous",\n "endpoint": "https://{your-custom-endpoint}.openai.azure.com/"\n}`, + ) as Record; const instance = AnonymousConnection.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/connection/api-key-connection.test.ts b/runtime/typescript/packages/core/tests/model/connection/api-key-connection.test.ts index b72270a7d..3881d6940 100644 --- a/runtime/typescript/packages/core/tests/model/connection/api-key-connection.test.ts +++ b/runtime/typescript/packages/core/tests/model/connection/api-key-connection.test.ts @@ -65,7 +65,9 @@ describe("ApiKeyConnection", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "kind": "key",\n "endpoint": "https://{your-custom-endpoint}.openai.azure.com/",\n "apiKey": "your-api-key"\n}`, + ) as Record; const instance = ApiKeyConnection.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/connection/authorization-code-flow.test.ts b/runtime/typescript/packages/core/tests/model/connection/authorization-code-flow.test.ts index 0f1b912ca..76fc5633e 100644 --- a/runtime/typescript/packages/core/tests/model/connection/authorization-code-flow.test.ts +++ b/runtime/typescript/packages/core/tests/model/connection/authorization-code-flow.test.ts @@ -18,12 +18,6 @@ describe("AuthorizationCodeFlow", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = AuthorizationCodeFlow.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new AuthorizationCodeFlow(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/connection/connection.test.ts b/runtime/typescript/packages/core/tests/model/connection/connection.test.ts index 193aa0bd9..c93fc3bdf 100644 --- a/runtime/typescript/packages/core/tests/model/connection/connection.test.ts +++ b/runtime/typescript/packages/core/tests/model/connection/connection.test.ts @@ -5,18 +5,6 @@ import { Connection } from "../../../src/model/index"; describe("Connection", () => { - describe("construction", () => { - it("should create a new instance with defaults", () => { - const instance = new Connection(); - expect(instance).toBeDefined(); - }); - - it("should create a new instance with partial initialization", () => { - const instance = new Connection({}); - expect(instance).toBeDefined(); - }); - }); - describe("JSON serialization", () => { it("should load from JSON - example 1", () => { const json = `{\n "kind": "reference",\n "authenticationMode": "system",\n "usageDescription": "This will allow the agent to respond to an email on your behalf"\n}`; diff --git a/runtime/typescript/packages/core/tests/model/connection/device-authorization.test.ts b/runtime/typescript/packages/core/tests/model/connection/device-authorization.test.ts index a459ebdee..47344da06 100644 --- a/runtime/typescript/packages/core/tests/model/connection/device-authorization.test.ts +++ b/runtime/typescript/packages/core/tests/model/connection/device-authorization.test.ts @@ -18,12 +18,6 @@ describe("DeviceAuthorization", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = DeviceAuthorization.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new DeviceAuthorization(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/connection/foundry-connection.test.ts b/runtime/typescript/packages/core/tests/model/connection/foundry-connection.test.ts index b691b3fef..047abe111 100644 --- a/runtime/typescript/packages/core/tests/model/connection/foundry-connection.test.ts +++ b/runtime/typescript/packages/core/tests/model/connection/foundry-connection.test.ts @@ -69,7 +69,9 @@ describe("FoundryConnection", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "kind": "foundry",\n "endpoint": "https://myresource.services.ai.azure.com/api/projects/myproject",\n "name": "my-openai-connection",\n "connectionType": "model"\n}`, + ) as Record; const instance = FoundryConnection.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/connection/o-auth-connection.test.ts b/runtime/typescript/packages/core/tests/model/connection/o-auth-connection.test.ts index 6119454b6..1b17b500b 100644 --- a/runtime/typescript/packages/core/tests/model/connection/o-auth-connection.test.ts +++ b/runtime/typescript/packages/core/tests/model/connection/o-auth-connection.test.ts @@ -73,7 +73,9 @@ describe("OAuthConnection", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "kind": "oauth",\n "endpoint": "https://api.example.com",\n "clientId": "your-client-id",\n "clientSecret": "your-client-secret",\n "tokenUrl": "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",\n "scopes": [\n "https://cognitiveservices.azure.com/.default"\n ]\n}`, + ) as Record; const instance = OAuthConnection.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/connection/o-auth-token.test.ts b/runtime/typescript/packages/core/tests/model/connection/o-auth-token.test.ts index 5cc56fbfc..7afbaba86 100644 --- a/runtime/typescript/packages/core/tests/model/connection/o-auth-token.test.ts +++ b/runtime/typescript/packages/core/tests/model/connection/o-auth-token.test.ts @@ -18,12 +18,6 @@ describe("OAuthToken", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = OAuthToken.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new OAuthToken(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/connection/reference-connection.test.ts b/runtime/typescript/packages/core/tests/model/connection/reference-connection.test.ts index 4b46d0f53..9e50adcc3 100644 --- a/runtime/typescript/packages/core/tests/model/connection/reference-connection.test.ts +++ b/runtime/typescript/packages/core/tests/model/connection/reference-connection.test.ts @@ -61,7 +61,9 @@ describe("ReferenceConnection", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "kind": "reference",\n "name": "my-reference-connection",\n "target": "my-target-resource"\n}`, + ) as Record; const instance = ReferenceConnection.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/connection/remote-connection.test.ts b/runtime/typescript/packages/core/tests/model/connection/remote-connection.test.ts index bbc29a6cd..0947e4597 100644 --- a/runtime/typescript/packages/core/tests/model/connection/remote-connection.test.ts +++ b/runtime/typescript/packages/core/tests/model/connection/remote-connection.test.ts @@ -65,7 +65,9 @@ describe("RemoteConnection", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "kind": "remote",\n "name": "my-reference-connection",\n "endpoint": "https://{your-custom-endpoint}.openai.azure.com/"\n}`, + ) as Record; const instance = RemoteConnection.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/conversation/audio-part.test.ts b/runtime/typescript/packages/core/tests/model/conversation/audio-part.test.ts index 348fc8390..bff0b41c1 100644 --- a/runtime/typescript/packages/core/tests/model/conversation/audio-part.test.ts +++ b/runtime/typescript/packages/core/tests/model/conversation/audio-part.test.ts @@ -57,7 +57,9 @@ describe("AudioPart", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "source": "https://example.com/audio.wav",\n "mediaType": "audio/wav"\n}`, + ) as Record; const instance = AudioPart.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/conversation/content-part.test.ts b/runtime/typescript/packages/core/tests/model/conversation/content-part.test.ts index ecd3cecea..75fc1b6cc 100644 --- a/runtime/typescript/packages/core/tests/model/conversation/content-part.test.ts +++ b/runtime/typescript/packages/core/tests/model/conversation/content-part.test.ts @@ -5,15 +5,7 @@ import { ContentPart } from "../../../src/model/index"; describe("ContentPart", () => { - describe("construction", () => { - it("should create a new instance with defaults", () => { - const instance = new ContentPart(); - expect(instance).toBeDefined(); - }); - - it("should create a new instance with partial initialization", () => { - const instance = new ContentPart({}); - expect(instance).toBeDefined(); - }); + it("should be defined", () => { + expect(ContentPart).toBeDefined(); }); }); diff --git a/runtime/typescript/packages/core/tests/model/conversation/file-part.test.ts b/runtime/typescript/packages/core/tests/model/conversation/file-part.test.ts index c83c5ee59..19d263da5 100644 --- a/runtime/typescript/packages/core/tests/model/conversation/file-part.test.ts +++ b/runtime/typescript/packages/core/tests/model/conversation/file-part.test.ts @@ -57,7 +57,9 @@ describe("FilePart", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "source": "https://example.com/document.pdf",\n "mediaType": "application/pdf"\n}`, + ) as Record; const instance = FilePart.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/conversation/image-part.test.ts b/runtime/typescript/packages/core/tests/model/conversation/image-part.test.ts index 827cbe235..d33ee1448 100644 --- a/runtime/typescript/packages/core/tests/model/conversation/image-part.test.ts +++ b/runtime/typescript/packages/core/tests/model/conversation/image-part.test.ts @@ -61,7 +61,9 @@ describe("ImagePart", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "source": "https://example.com/image.png",\n "detail": "auto",\n "mediaType": "image/png"\n}`, + ) as Record; const instance = ImagePart.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/conversation/message.test.ts b/runtime/typescript/packages/core/tests/model/conversation/message.test.ts index 3816ada64..7112f92d4 100644 --- a/runtime/typescript/packages/core/tests/model/conversation/message.test.ts +++ b/runtime/typescript/packages/core/tests/model/conversation/message.test.ts @@ -74,7 +74,9 @@ describe("Message", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "role": "user",\n "parts": [\n {\n "kind": "text",\n "value": "Hello!"\n }\n ],\n "metadata": {\n "source": "user-input"\n }\n}`, + ) as Record; const instance = Message.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/conversation/text-part.test.ts b/runtime/typescript/packages/core/tests/model/conversation/text-part.test.ts index 1c80f3e0b..36921111b 100644 --- a/runtime/typescript/packages/core/tests/model/conversation/text-part.test.ts +++ b/runtime/typescript/packages/core/tests/model/conversation/text-part.test.ts @@ -53,7 +53,10 @@ describe("TextPart", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse(`{\n "value": "Hello, world!"\n}`) as Record< + string, + unknown + >; const instance = TextPart.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/conversation/thread-marker.test.ts b/runtime/typescript/packages/core/tests/model/conversation/thread-marker.test.ts index 2f57c8989..f2b294d5f 100644 --- a/runtime/typescript/packages/core/tests/model/conversation/thread-marker.test.ts +++ b/runtime/typescript/packages/core/tests/model/conversation/thread-marker.test.ts @@ -57,7 +57,9 @@ describe("ThreadMarker", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "name": "thread",\n "kind": "thread"\n}`, + ) as Record; const instance = ThreadMarker.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/conversation/tool-call.test.ts b/runtime/typescript/packages/core/tests/model/conversation/tool-call.test.ts index eac12b40a..1b5654dd4 100644 --- a/runtime/typescript/packages/core/tests/model/conversation/tool-call.test.ts +++ b/runtime/typescript/packages/core/tests/model/conversation/tool-call.test.ts @@ -61,7 +61,9 @@ describe("ToolCall", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "call_abc123",\n "name": "get_weather",\n "arguments": "{\\"city\\": \\"Paris\\"}"\n}`, + ) as Record; const instance = ToolCall.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/conversation/tool-result.test.ts b/runtime/typescript/packages/core/tests/model/conversation/tool-result.test.ts index 8f74c02e0..1a812cff0 100644 --- a/runtime/typescript/packages/core/tests/model/conversation/tool-result.test.ts +++ b/runtime/typescript/packages/core/tests/model/conversation/tool-result.test.ts @@ -73,7 +73,9 @@ describe("ToolResult", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "parts": [\n {\n "kind": "text",\n "value": "72°F and sunny"\n }\n ],\n "errorKind": "missing_tool",\n "errorMessage": "Tool 'get_weather' is not registered",\n "durationMs": 42\n}`, + ) as Record; const instance = ToolResult.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/core/array-property.test.ts b/runtime/typescript/packages/core/tests/model/core/array-property.test.ts index 08e11b9ba..66b3303e5 100644 --- a/runtime/typescript/packages/core/tests/model/core/array-property.test.ts +++ b/runtime/typescript/packages/core/tests/model/core/array-property.test.ts @@ -49,7 +49,9 @@ describe("ArrayProperty", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "items": {\n "kind": "string"\n }\n}`, + ) as Record; const instance = ArrayProperty.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/core/file-not-found-error.test.ts b/runtime/typescript/packages/core/tests/model/core/file-not-found-error.test.ts index e4e5036fa..ac39a8704 100644 --- a/runtime/typescript/packages/core/tests/model/core/file-not-found-error.test.ts +++ b/runtime/typescript/packages/core/tests/model/core/file-not-found-error.test.ts @@ -61,7 +61,9 @@ describe("FileNotFoundError", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "message": "Prompty file not found: ./chat.prompty",\n "path": "./chat.prompty"\n}`, + ) as Record; const instance = FileNotFoundError.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/core/invoker-error.test.ts b/runtime/typescript/packages/core/tests/model/core/invoker-error.test.ts index 248de8ada..b1653c102 100644 --- a/runtime/typescript/packages/core/tests/model/core/invoker-error.test.ts +++ b/runtime/typescript/packages/core/tests/model/core/invoker-error.test.ts @@ -65,7 +65,9 @@ describe("InvokerError", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "message": "No renderer registered for key: jinja2",\n "component": "renderer",\n "key": "jinja2"\n}`, + ) as Record; const instance = InvokerError.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/core/object-property.test.ts b/runtime/typescript/packages/core/tests/model/core/object-property.test.ts index 09d9567a8..bc1f00d01 100644 --- a/runtime/typescript/packages/core/tests/model/core/object-property.test.ts +++ b/runtime/typescript/packages/core/tests/model/core/object-property.test.ts @@ -49,7 +49,9 @@ describe("ObjectProperty", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "properties": {\n "property1": {\n "kind": "string"\n },\n "property2": {\n "kind": "number"\n }\n }\n}`, + ) as Record; const instance = ObjectProperty.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/core/union-property.test.ts b/runtime/typescript/packages/core/tests/model/core/union-property.test.ts index 766e6a07c..8fb02192a 100644 --- a/runtime/typescript/packages/core/tests/model/core/union-property.test.ts +++ b/runtime/typescript/packages/core/tests/model/core/union-property.test.ts @@ -49,7 +49,9 @@ describe("UnionProperty", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "anyOf": [\n {\n "kind": "string"\n },\n {\n "kind": "boolean"\n }\n ]\n}`, + ) as Record; const instance = UnionProperty.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/core/validation-error.test.ts b/runtime/typescript/packages/core/tests/model/core/validation-error.test.ts index fde328f07..110673286 100644 --- a/runtime/typescript/packages/core/tests/model/core/validation-error.test.ts +++ b/runtime/typescript/packages/core/tests/model/core/validation-error.test.ts @@ -61,7 +61,9 @@ describe("ValidationError", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "message": "Missing required input: firstName",\n "property": "firstName",\n "constraint": "required"\n}`, + ) as Record; const instance = ValidationError.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/core/validation-result.test.ts b/runtime/typescript/packages/core/tests/model/core/validation-result.test.ts index 41ea55c0b..1b25f6b65 100644 --- a/runtime/typescript/packages/core/tests/model/core/validation-result.test.ts +++ b/runtime/typescript/packages/core/tests/model/core/validation-result.test.ts @@ -53,7 +53,9 @@ describe("ValidationResult", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "valid": true,\n "errors": []\n}`, + ) as Record; const instance = ValidationResult.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/checkpoint.test.ts b/runtime/typescript/packages/core/tests/model/events/checkpoint.test.ts index 26c11255c..8f5b058d5 100644 --- a/runtime/typescript/packages/core/tests/model/events/checkpoint.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/checkpoint.test.ts @@ -73,7 +73,9 @@ describe("Checkpoint", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "chk_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_001",\n "checkpointNumber": 3,\n "title": "Added harness contracts",\n "createdAt": "2026-06-09T20:00:00Z"\n}`, + ) as Record; const instance = Checkpoint.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/compaction-complete-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/compaction-complete-payload.test.ts index f8f56d98c..c0d51a4d5 100644 --- a/runtime/typescript/packages/core/tests/model/events/compaction-complete-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/compaction-complete-payload.test.ts @@ -61,7 +61,9 @@ describe("CompactionCompletePayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "removed": 5,\n "remaining": 3,\n "summaryLength": 1200\n}`, + ) as Record; const instance = CompactionCompletePayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/compaction-failed-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/compaction-failed-payload.test.ts index 211dad763..6bf4daa63 100644 --- a/runtime/typescript/packages/core/tests/model/events/compaction-failed-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/compaction-failed-payload.test.ts @@ -57,7 +57,9 @@ describe("CompactionFailedPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "message": "Summarization prompt exceeded context window"\n}`, + ) as Record; const instance = CompactionFailedPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/compaction-start-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/compaction-start-payload.test.ts index d4b87fb25..31126ccf7 100644 --- a/runtime/typescript/packages/core/tests/model/events/compaction-start-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/compaction-start-payload.test.ts @@ -53,7 +53,10 @@ describe("CompactionStartPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse(`{\n "droppedCount": 5\n}`) as Record< + string, + unknown + >; const instance = CompactionStartPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/done-event-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/done-event-payload.test.ts index 6ff233721..109d389d0 100644 --- a/runtime/typescript/packages/core/tests/model/events/done-event-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/done-event-payload.test.ts @@ -18,12 +18,6 @@ describe("DoneEventPayload", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = DoneEventPayload.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new DoneEventPayload(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/events/error-chunk.test.ts b/runtime/typescript/packages/core/tests/model/events/error-chunk.test.ts index b0980c7f0..d3e00ba3c 100644 --- a/runtime/typescript/packages/core/tests/model/events/error-chunk.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/error-chunk.test.ts @@ -53,7 +53,9 @@ describe("ErrorChunk", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "message": "Rate limit exceeded"\n}`, + ) as Record; const instance = ErrorChunk.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/error-event-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/error-event-payload.test.ts index 2dee91817..22b66adaa 100644 --- a/runtime/typescript/packages/core/tests/model/events/error-event-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/error-event-payload.test.ts @@ -61,7 +61,9 @@ describe("ErrorEventPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "message": "Rate limit exceeded",\n "errorKind": "rate_limit",\n "phase": "llm"\n}`, + ) as Record; const instance = ErrorEventPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/harness-context.test.ts b/runtime/typescript/packages/core/tests/model/events/harness-context.test.ts index da18d798a..5bba39fa5 100644 --- a/runtime/typescript/packages/core/tests/model/events/harness-context.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/harness-context.test.ts @@ -57,7 +57,9 @@ describe("HarnessContext", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "cwd": "/workspace/project",\n "gitRoot": "/workspace/project"\n}`, + ) as Record; const instance = HarnessContext.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/hook-end-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/hook-end-payload.test.ts index 645d8b0e6..6f7a8e7d4 100644 --- a/runtime/typescript/packages/core/tests/model/events/hook-end-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/hook-end-payload.test.ts @@ -69,7 +69,9 @@ describe("HookEndPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "hookInvocationId": "hook_abc123",\n "hookType": "preToolUse",\n "success": true,\n "durationMs": 12,\n "error": "hook failed"\n}`, + ) as Record; const instance = HookEndPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/hook-start-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/hook-start-payload.test.ts index 77b0ac3cc..87039d279 100644 --- a/runtime/typescript/packages/core/tests/model/events/hook-start-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/hook-start-payload.test.ts @@ -57,7 +57,9 @@ describe("HookStartPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "hookInvocationId": "hook_abc123",\n "hookType": "preToolUse"\n}`, + ) as Record; const instance = HookStartPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/host-tool-request.test.ts b/runtime/typescript/packages/core/tests/model/events/host-tool-request.test.ts index 74acbfa48..a468e3dd4 100644 --- a/runtime/typescript/packages/core/tests/model/events/host-tool-request.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/host-tool-request.test.ts @@ -65,7 +65,9 @@ describe("HostToolRequest", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "requestId": "exec_abc123",\n "toolCallId": "call_abc123",\n "toolName": "powershell",\n "workingDirectory": "/workspace/project"\n}`, + ) as Record; const instance = HostToolRequest.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/host-tool-result.test.ts b/runtime/typescript/packages/core/tests/model/events/host-tool-result.test.ts index 95c431cdf..2aa3f00fe 100644 --- a/runtime/typescript/packages/core/tests/model/events/host-tool-result.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/host-tool-result.test.ts @@ -77,7 +77,9 @@ describe("HostToolResult", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "requestId": "exec_abc123",\n "toolCallId": "call_abc123",\n "toolName": "powershell",\n "success": true,\n "exitCode": 0,\n "durationMs": 250,\n "errorKind": "timeout"\n}`, + ) as Record; const instance = HostToolResult.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/llm-complete-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/llm-complete-payload.test.ts index 239538999..260a78963 100644 --- a/runtime/typescript/packages/core/tests/model/events/llm-complete-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/llm-complete-payload.test.ts @@ -61,7 +61,9 @@ describe("LlmCompletePayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "requestId": "req_abc123",\n "serviceRequestId": "srv_abc123",\n "durationMs": 820\n}`, + ) as Record; const instance = LlmCompletePayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/llm-start-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/llm-start-payload.test.ts index bb7a0c6b4..6b5a1c2e2 100644 --- a/runtime/typescript/packages/core/tests/model/events/llm-start-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/llm-start-payload.test.ts @@ -65,7 +65,9 @@ describe("LlmStartPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "provider": "openai",\n "modelId": "gpt-4o-mini",\n "messageCount": 4,\n "attempt": 0\n}`, + ) as Record; const instance = LlmStartPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/messages-updated-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/messages-updated-payload.test.ts index 08df2b508..0c9b82047 100644 --- a/runtime/typescript/packages/core/tests/model/events/messages-updated-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/messages-updated-payload.test.ts @@ -57,7 +57,9 @@ describe("MessagesUpdatedPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "reason": "tool_results",\n "removed": 2\n}`, + ) as Record; const instance = MessagesUpdatedPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/permission-completed-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/permission-completed-payload.test.ts index 84e2b7ff3..d34565b27 100644 --- a/runtime/typescript/packages/core/tests/model/events/permission-completed-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/permission-completed-payload.test.ts @@ -69,7 +69,9 @@ describe("PermissionCompletedPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "requestId": "perm_abc123",\n "toolCallId": "call_abc123",\n "permission": "tool.execute",\n "approved": true,\n "reason": "user_approved"\n}`, + ) as Record; const instance = PermissionCompletedPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/permission-decision.test.ts b/runtime/typescript/packages/core/tests/model/events/permission-decision.test.ts index 2644d421d..8d27b7342 100644 --- a/runtime/typescript/packages/core/tests/model/events/permission-decision.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/permission-decision.test.ts @@ -69,7 +69,9 @@ describe("PermissionDecision", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "requestId": "perm_abc123",\n "toolCallId": "call_abc123",\n "permission": "tool.execute",\n "approved": true,\n "reason": "user_approved"\n}`, + ) as Record; const instance = PermissionDecision.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/permission-request.test.ts b/runtime/typescript/packages/core/tests/model/events/permission-request.test.ts index aea9ef96c..f8dca7752 100644 --- a/runtime/typescript/packages/core/tests/model/events/permission-request.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/permission-request.test.ts @@ -69,7 +69,9 @@ describe("PermissionRequest", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "requestId": "perm_abc123",\n "toolCallId": "call_abc123",\n "permission": "tool.execute",\n "target": "shell",\n "promptRequest": "Allow shell to run tests?"\n}`, + ) as Record; const instance = PermissionRequest.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/permission-requested-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/permission-requested-payload.test.ts index 540865ee9..0e0bad716 100644 --- a/runtime/typescript/packages/core/tests/model/events/permission-requested-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/permission-requested-payload.test.ts @@ -69,7 +69,9 @@ describe("PermissionRequestedPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "requestId": "perm_abc123",\n "toolCallId": "call_abc123",\n "permission": "tool.execute",\n "target": "shell",\n "promptRequest": "Allow shell to run tests?"\n}`, + ) as Record; const instance = PermissionRequestedPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/redacted-field.test.ts b/runtime/typescript/packages/core/tests/model/events/redacted-field.test.ts index 72c584841..9f18cbb4f 100644 --- a/runtime/typescript/packages/core/tests/model/events/redacted-field.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/redacted-field.test.ts @@ -61,7 +61,9 @@ describe("RedactedField", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "path": "$.arguments.apiKey",\n "mode": "redacted",\n "reason": "secret"\n}`, + ) as Record; const instance = RedactedField.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/redaction-metadata.test.ts b/runtime/typescript/packages/core/tests/model/events/redaction-metadata.test.ts index aaa2d75b5..de78d430f 100644 --- a/runtime/typescript/packages/core/tests/model/events/redaction-metadata.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/redaction-metadata.test.ts @@ -57,7 +57,9 @@ describe("RedactionMetadata", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "sanitized": true,\n "policy": "default-v1"\n}`, + ) as Record; const instance = RedactionMetadata.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/retry-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/retry-payload.test.ts index ef954f14f..e85eea93d 100644 --- a/runtime/typescript/packages/core/tests/model/events/retry-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/retry-payload.test.ts @@ -69,7 +69,9 @@ describe("RetryPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "operation": "llm",\n "attempt": 2,\n "maxAttempts": 3,\n "delayMs": 1250,\n "reason": "rate_limit"\n}`, + ) as Record; const instance = RetryPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/session-end-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/session-end-payload.test.ts index f0386c156..9f5dc316a 100644 --- a/runtime/typescript/packages/core/tests/model/events/session-end-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/session-end-payload.test.ts @@ -65,7 +65,9 @@ describe("SessionEndPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "sessionId": "sess_abc123",\n "status": "success",\n "reason": "complete",\n "durationMs": 12500\n}`, + ) as Record; const instance = SessionEndPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/session-event.test.ts b/runtime/typescript/packages/core/tests/model/events/session-event.test.ts index 2d6190913..a7652d754 100644 --- a/runtime/typescript/packages/core/tests/model/events/session-event.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/session-event.test.ts @@ -73,7 +73,9 @@ describe("SessionEvent", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "evt_abc123",\n "timestamp": "2026-06-09T20:00:00Z",\n "sessionId": "sess_abc123",\n "turnId": "turn_001",\n "parentId": "evt_parent",\n "spanId": "span_hook_001"\n}`, + ) as Record; const instance = SessionEvent.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/session-file-ref.test.ts b/runtime/typescript/packages/core/tests/model/events/session-file-ref.test.ts index e215ea18c..4d6066d89 100644 --- a/runtime/typescript/packages/core/tests/model/events/session-file-ref.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/session-file-ref.test.ts @@ -69,7 +69,9 @@ describe("SessionFileRef", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "sessionId": "sess_abc123",\n "path": "src/index.ts",\n "toolName": "view",\n "turnIndex": 2,\n "firstSeenAt": "2026-06-09T20:00:00Z"\n}`, + ) as Record; const instance = SessionFileRef.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/session-ref.test.ts b/runtime/typescript/packages/core/tests/model/events/session-ref.test.ts index 0d3ac52d4..24cb924c1 100644 --- a/runtime/typescript/packages/core/tests/model/events/session-ref.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/session-ref.test.ts @@ -69,7 +69,9 @@ describe("SessionRef", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "sessionId": "sess_abc123",\n "refType": "issue",\n "refValue": "owner/repo#123",\n "turnIndex": 2,\n "createdAt": "2026-06-09T20:00:00Z"\n}`, + ) as Record; const instance = SessionRef.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/session-start-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/session-start-payload.test.ts index bc804b7ef..4693e0d90 100644 --- a/runtime/typescript/packages/core/tests/model/events/session-start-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/session-start-payload.test.ts @@ -81,7 +81,9 @@ describe("SessionStartPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "sessionId": "sess_abc123",\n "schemaVersion": "1",\n "producer": "prompty-agent",\n "runtime": "typescript",\n "promptyVersion": "2.0.0",\n "startTime": "2026-06-09T20:00:00Z",\n "selectedModel": "gpt-4o-mini",\n "reasoningEffort": "medium"\n}`, + ) as Record; const instance = SessionStartPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/session-summary.test.ts b/runtime/typescript/packages/core/tests/model/events/session-summary.test.ts index 5d74c9795..0be7aeb81 100644 --- a/runtime/typescript/packages/core/tests/model/events/session-summary.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/session-summary.test.ts @@ -69,7 +69,9 @@ describe("SessionSummary", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "sessionId": "sess_abc123",\n "status": "success",\n "turns": 5,\n "checkpoints": 2,\n "durationMs": 12500\n}`, + ) as Record; const instance = SessionSummary.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/session-trace.test.ts b/runtime/typescript/packages/core/tests/model/events/session-trace.test.ts index 1284e1ae7..fed88603d 100644 --- a/runtime/typescript/packages/core/tests/model/events/session-trace.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/session-trace.test.ts @@ -19,7 +19,7 @@ describe("SessionTrace", () => { describe("JSON serialization", () => { it("should load from JSON - example 1", () => { - const json = `{\n "version": "1",\n "runtime": "typescript",\n "promptyVersion": "2.0.0",\n "sessionId": "sess_abc123"\n}`; + const json = `{\n "version": "1",\n "runtime": "typescript",\n "promptyVersion": "2.0.0",\n "sessionId": "sess_abc123",\n "events": [\n {\n "id": "evt_abc123",\n "type": "session_start",\n "timestamp": "2026-06-09T20:00:00Z",\n "sessionId": "sess_abc123",\n "turnId": "turn_001",\n "parentId": "evt_parent",\n "spanId": "span_hook_001"\n }\n ]\n}`; const instance = SessionTrace.fromJson(json); expect(instance).toBeDefined(); expect(instance.version).toEqual("1"); @@ -29,7 +29,7 @@ describe("SessionTrace", () => { }); it("should round-trip JSON - example 1", () => { - const json = `{\n "version": "1",\n "runtime": "typescript",\n "promptyVersion": "2.0.0",\n "sessionId": "sess_abc123"\n}`; + const json = `{\n "version": "1",\n "runtime": "typescript",\n "promptyVersion": "2.0.0",\n "sessionId": "sess_abc123",\n "events": [\n {\n "id": "evt_abc123",\n "type": "session_start",\n "timestamp": "2026-06-09T20:00:00Z",\n "sessionId": "sess_abc123",\n "turnId": "turn_001",\n "parentId": "evt_parent",\n "spanId": "span_hook_001"\n }\n ]\n}`; const instance = SessionTrace.fromJson(json); const output = instance.toJson(); const reloaded = SessionTrace.fromJson(output); @@ -42,7 +42,7 @@ describe("SessionTrace", () => { describe("YAML serialization", () => { it("should load from YAML - example 1", () => { - const yaml = `version: "1"\nruntime: typescript\npromptyVersion: 2.0.0\nsessionId: sess_abc123\n`; + const yaml = `version: "1"\nruntime: typescript\npromptyVersion: 2.0.0\nsessionId: sess_abc123\nevents:\n - id: evt_abc123\n type: session_start\n timestamp: "2026-06-09T20:00:00Z"\n sessionId: sess_abc123\n turnId: turn_001\n parentId: evt_parent\n spanId: span_hook_001\n`; const instance = SessionTrace.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.version).toEqual("1"); @@ -52,7 +52,7 @@ describe("SessionTrace", () => { }); it("should round-trip YAML - example 1", () => { - const yaml = `version: "1"\nruntime: typescript\npromptyVersion: 2.0.0\nsessionId: sess_abc123\n`; + const yaml = `version: "1"\nruntime: typescript\npromptyVersion: 2.0.0\nsessionId: sess_abc123\nevents:\n - id: evt_abc123\n type: session_start\n timestamp: "2026-06-09T20:00:00Z"\n sessionId: sess_abc123\n turnId: turn_001\n parentId: evt_parent\n spanId: span_hook_001\n`; const instance = SessionTrace.fromYaml(yaml); const output = instance.toYaml(); const reloaded = SessionTrace.fromYaml(output); @@ -65,7 +65,9 @@ describe("SessionTrace", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "version": "1",\n "runtime": "typescript",\n "promptyVersion": "2.0.0",\n "sessionId": "sess_abc123",\n "events": [\n {\n "id": "evt_abc123",\n "type": "session_start",\n "timestamp": "2026-06-09T20:00:00Z",\n "sessionId": "sess_abc123",\n "turnId": "turn_001",\n "parentId": "evt_parent",\n "spanId": "span_hook_001"\n }\n ]\n}`, + ) as Record; const instance = SessionTrace.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/session-warning-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/session-warning-payload.test.ts index 1b2ab319d..53a0d4903 100644 --- a/runtime/typescript/packages/core/tests/model/events/session-warning-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/session-warning-payload.test.ts @@ -57,7 +57,9 @@ describe("SessionWarningPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "warningType": "remote",\n "message": "Remote session disabled"\n}`, + ) as Record; const instance = SessionWarningPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/status-event-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/status-event-payload.test.ts index fee9fb58e..44ea8a5eb 100644 --- a/runtime/typescript/packages/core/tests/model/events/status-event-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/status-event-payload.test.ts @@ -53,7 +53,9 @@ describe("StatusEventPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "message": "Starting iteration 3"\n}`, + ) as Record; const instance = StatusEventPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/stream-chunk.test.ts b/runtime/typescript/packages/core/tests/model/events/stream-chunk.test.ts index 186574a24..6603bdc39 100644 --- a/runtime/typescript/packages/core/tests/model/events/stream-chunk.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/stream-chunk.test.ts @@ -5,15 +5,7 @@ import { StreamChunk } from "../../../src/model/index"; describe("StreamChunk", () => { - describe("construction", () => { - it("should create a new instance with defaults", () => { - const instance = new StreamChunk(); - expect(instance).toBeDefined(); - }); - - it("should create a new instance with partial initialization", () => { - const instance = new StreamChunk({}); - expect(instance).toBeDefined(); - }); + it("should be defined", () => { + expect(StreamChunk).toBeDefined(); }); }); diff --git a/runtime/typescript/packages/core/tests/model/events/text-chunk.test.ts b/runtime/typescript/packages/core/tests/model/events/text-chunk.test.ts index 7e7f5690b..281ae44f1 100644 --- a/runtime/typescript/packages/core/tests/model/events/text-chunk.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/text-chunk.test.ts @@ -53,7 +53,10 @@ describe("TextChunk", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse(`{\n "value": "Hello"\n}`) as Record< + string, + unknown + >; const instance = TextChunk.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/thinking-chunk.test.ts b/runtime/typescript/packages/core/tests/model/events/thinking-chunk.test.ts index 49bfcf715..d71e7e23e 100644 --- a/runtime/typescript/packages/core/tests/model/events/thinking-chunk.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/thinking-chunk.test.ts @@ -53,7 +53,9 @@ describe("ThinkingChunk", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "value": "Let me consider..."\n}`, + ) as Record; const instance = ThinkingChunk.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/thinking-event-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/thinking-event-payload.test.ts index b9f32d05a..dacd28070 100644 --- a/runtime/typescript/packages/core/tests/model/events/thinking-event-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/thinking-event-payload.test.ts @@ -53,7 +53,9 @@ describe("ThinkingEventPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "token": "Let me consider..."\n}`, + ) as Record; const instance = ThinkingEventPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/token-event-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/token-event-payload.test.ts index 218d8e4c2..ba07fdaa9 100644 --- a/runtime/typescript/packages/core/tests/model/events/token-event-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/token-event-payload.test.ts @@ -53,7 +53,10 @@ describe("TokenEventPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse(`{\n "token": "Hello"\n}`) as Record< + string, + unknown + >; const instance = TokenEventPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/tool-call-complete-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/tool-call-complete-payload.test.ts index 596b349e2..88ec319ff 100644 --- a/runtime/typescript/packages/core/tests/model/events/tool-call-complete-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/tool-call-complete-payload.test.ts @@ -69,7 +69,9 @@ describe("ToolCallCompletePayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "call_abc123",\n "name": "get_weather",\n "success": true,\n "durationMs": 42,\n "errorKind": "timeout"\n}`, + ) as Record; const instance = ToolCallCompletePayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/tool-call-start-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/tool-call-start-payload.test.ts index 0c4f7fc27..13fce1b75 100644 --- a/runtime/typescript/packages/core/tests/model/events/tool-call-start-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/tool-call-start-payload.test.ts @@ -61,7 +61,9 @@ describe("ToolCallStartPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "call_abc123",\n "name": "get_weather",\n "arguments": "{\\"city\\": \\"Paris\\"}"\n}`, + ) as Record; const instance = ToolCallStartPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/tool-chunk.test.ts b/runtime/typescript/packages/core/tests/model/events/tool-chunk.test.ts index ec9337146..02322f52c 100644 --- a/runtime/typescript/packages/core/tests/model/events/tool-chunk.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/tool-chunk.test.ts @@ -49,7 +49,9 @@ describe("ToolChunk", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "toolCall": {\n "id": "call_abc123",\n "name": "get_weather",\n "arguments": "{\\"city\\": \\"Paris\\"}"\n }\n}`, + ) as Record; const instance = ToolChunk.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/tool-execution-complete-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/tool-execution-complete-payload.test.ts index 80e198ffd..7ee7acd95 100644 --- a/runtime/typescript/packages/core/tests/model/events/tool-execution-complete-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/tool-execution-complete-payload.test.ts @@ -77,7 +77,9 @@ describe("ToolExecutionCompletePayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "requestId": "exec_abc123",\n "toolCallId": "call_abc123",\n "toolName": "powershell",\n "success": true,\n "exitCode": 0,\n "durationMs": 250,\n "errorKind": "timeout"\n}`, + ) as Record; const instance = ToolExecutionCompletePayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/tool-execution-start-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/tool-execution-start-payload.test.ts index fdee26151..d15f53efb 100644 --- a/runtime/typescript/packages/core/tests/model/events/tool-execution-start-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/tool-execution-start-payload.test.ts @@ -65,7 +65,9 @@ describe("ToolExecutionStartPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "requestId": "exec_abc123",\n "toolCallId": "call_abc123",\n "toolName": "powershell",\n "workingDirectory": "/workspace/project"\n}`, + ) as Record; const instance = ToolExecutionStartPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/tool-result-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/tool-result-payload.test.ts index e61f87785..0a0c0b77f 100644 --- a/runtime/typescript/packages/core/tests/model/events/tool-result-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/tool-result-payload.test.ts @@ -53,7 +53,9 @@ describe("ToolResultPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "name": "get_weather",\n "result": {\n "parts": [\n {\n "kind": "text",\n "value": "72°F and sunny"\n }\n ]\n }\n}`, + ) as Record; const instance = ToolResultPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/trajectory-event.test.ts b/runtime/typescript/packages/core/tests/model/events/trajectory-event.test.ts index e53ae938e..2d25b131d 100644 --- a/runtime/typescript/packages/core/tests/model/events/trajectory-event.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/trajectory-event.test.ts @@ -77,7 +77,9 @@ describe("TrajectoryEvent", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "traj_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_001",\n "toolCallId": "call_abc123",\n "turnIndex": 4,\n "eventType": "command",\n "createdAt": "2026-06-09T20:00:00Z"\n}`, + ) as Record; const instance = TrajectoryEvent.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/turn-end-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/turn-end-payload.test.ts index 4a7b8252e..ed0dc09d3 100644 --- a/runtime/typescript/packages/core/tests/model/events/turn-end-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/turn-end-payload.test.ts @@ -57,7 +57,9 @@ describe("TurnEndPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "iterations": 2,\n "durationMs": 1500\n}`, + ) as Record; const instance = TurnEndPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/turn-event.test.ts b/runtime/typescript/packages/core/tests/model/events/turn-event.test.ts index f432a24ca..57f2a38f2 100644 --- a/runtime/typescript/packages/core/tests/model/events/turn-event.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/turn-event.test.ts @@ -73,7 +73,9 @@ describe("TurnEvent", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "evt_abc123",\n "timestamp": "2026-06-09T20:00:00Z",\n "turnId": "turn_001",\n "iteration": 0,\n "parentId": "evt_parent",\n "spanId": "span_tool_001"\n}`, + ) as Record; const instance = TurnEvent.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/turn-start-payload.test.ts b/runtime/typescript/packages/core/tests/model/events/turn-start-payload.test.ts index 588712d84..810c918ad 100644 --- a/runtime/typescript/packages/core/tests/model/events/turn-start-payload.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/turn-start-payload.test.ts @@ -57,7 +57,9 @@ describe("TurnStartPayload", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "agent": "weather-agent",\n "maxIterations": 10\n}`, + ) as Record; const instance = TurnStartPayload.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/turn-summary.test.ts b/runtime/typescript/packages/core/tests/model/events/turn-summary.test.ts index 43c4969c6..3d446cd1c 100644 --- a/runtime/typescript/packages/core/tests/model/events/turn-summary.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/turn-summary.test.ts @@ -77,7 +77,9 @@ describe("TurnSummary", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "turnId": "turn_001",\n "status": "success",\n "iterations": 2,\n "llmCalls": 3,\n "toolCalls": 2,\n "retries": 1,\n "durationMs": 2500\n}`, + ) as Record; const instance = TurnSummary.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/turn-trace.test.ts b/runtime/typescript/packages/core/tests/model/events/turn-trace.test.ts index 34a79a4ee..8ef81d4a5 100644 --- a/runtime/typescript/packages/core/tests/model/events/turn-trace.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/turn-trace.test.ts @@ -19,7 +19,7 @@ describe("TurnTrace", () => { describe("JSON serialization", () => { it("should load from JSON - example 1", () => { - const json = `{\n "version": "1",\n "runtime": "typescript",\n "promptyVersion": "2.0.0"\n}`; + const json = `{\n "version": "1",\n "runtime": "typescript",\n "promptyVersion": "2.0.0",\n "events": [\n {\n "id": "evt_abc123",\n "type": "turn_start",\n "timestamp": "2026-06-09T20:00:00Z",\n "turnId": "turn_001",\n "iteration": 0,\n "parentId": "evt_parent",\n "spanId": "span_tool_001"\n }\n ]\n}`; const instance = TurnTrace.fromJson(json); expect(instance).toBeDefined(); expect(instance.version).toEqual("1"); @@ -28,7 +28,7 @@ describe("TurnTrace", () => { }); it("should round-trip JSON - example 1", () => { - const json = `{\n "version": "1",\n "runtime": "typescript",\n "promptyVersion": "2.0.0"\n}`; + const json = `{\n "version": "1",\n "runtime": "typescript",\n "promptyVersion": "2.0.0",\n "events": [\n {\n "id": "evt_abc123",\n "type": "turn_start",\n "timestamp": "2026-06-09T20:00:00Z",\n "turnId": "turn_001",\n "iteration": 0,\n "parentId": "evt_parent",\n "spanId": "span_tool_001"\n }\n ]\n}`; const instance = TurnTrace.fromJson(json); const output = instance.toJson(); const reloaded = TurnTrace.fromJson(output); @@ -40,7 +40,7 @@ describe("TurnTrace", () => { describe("YAML serialization", () => { it("should load from YAML - example 1", () => { - const yaml = `version: "1"\nruntime: typescript\npromptyVersion: 2.0.0\n`; + const yaml = `version: "1"\nruntime: typescript\npromptyVersion: 2.0.0\nevents:\n - id: evt_abc123\n type: turn_start\n timestamp: "2026-06-09T20:00:00Z"\n turnId: turn_001\n iteration: 0\n parentId: evt_parent\n spanId: span_tool_001\n`; const instance = TurnTrace.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.version).toEqual("1"); @@ -49,7 +49,7 @@ describe("TurnTrace", () => { }); it("should round-trip YAML - example 1", () => { - const yaml = `version: "1"\nruntime: typescript\npromptyVersion: 2.0.0\n`; + const yaml = `version: "1"\nruntime: typescript\npromptyVersion: 2.0.0\nevents:\n - id: evt_abc123\n type: turn_start\n timestamp: "2026-06-09T20:00:00Z"\n turnId: turn_001\n iteration: 0\n parentId: evt_parent\n spanId: span_tool_001\n`; const instance = TurnTrace.fromYaml(yaml); const output = instance.toYaml(); const reloaded = TurnTrace.fromYaml(output); @@ -61,7 +61,9 @@ describe("TurnTrace", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "version": "1",\n "runtime": "typescript",\n "promptyVersion": "2.0.0",\n "events": [\n {\n "id": "evt_abc123",\n "type": "turn_start",\n "timestamp": "2026-06-09T20:00:00Z",\n "turnId": "turn_001",\n "iteration": 0,\n "parentId": "evt_parent",\n "spanId": "span_tool_001"\n }\n ]\n}`, + ) as Record; const instance = TurnTrace.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/events/usage-chunk.test.ts b/runtime/typescript/packages/core/tests/model/events/usage-chunk.test.ts index cb5f907b3..e4a72ac3b 100644 --- a/runtime/typescript/packages/core/tests/model/events/usage-chunk.test.ts +++ b/runtime/typescript/packages/core/tests/model/events/usage-chunk.test.ts @@ -18,12 +18,6 @@ describe("UsageChunk", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = UsageChunk.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new UsageChunk(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/memory/memory-entry.test.ts b/runtime/typescript/packages/core/tests/model/memory/memory-entry.test.ts index 9e1ff1e2e..feffbd4a6 100644 --- a/runtime/typescript/packages/core/tests/model/memory/memory-entry.test.ts +++ b/runtime/typescript/packages/core/tests/model/memory/memory-entry.test.ts @@ -61,7 +61,9 @@ describe("MemoryEntry", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "content": "The user prefers concise answers.",\n "category": "core",\n "createdAt": "2026-06-09T20:00:00Z",\n "tags": [\n "preference",\n "tone"\n ]\n}`, + ) as Record; const instance = MemoryEntry.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/memory/memory-store.test.ts b/runtime/typescript/packages/core/tests/model/memory/memory-store.test.ts index 2d776e55c..95897c662 100644 --- a/runtime/typescript/packages/core/tests/model/memory/memory-store.test.ts +++ b/runtime/typescript/packages/core/tests/model/memory/memory-store.test.ts @@ -49,7 +49,10 @@ describe("MemoryStore", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse(`{\n "entries": []\n}`) as Record< + string, + unknown + >; const instance = MemoryStore.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/model/ai-resource-info.test.ts b/runtime/typescript/packages/core/tests/model/model/ai-resource-info.test.ts index a9f786a19..bf73f4ec4 100644 --- a/runtime/typescript/packages/core/tests/model/model/ai-resource-info.test.ts +++ b/runtime/typescript/packages/core/tests/model/model/ai-resource-info.test.ts @@ -18,12 +18,6 @@ describe("AiResourceInfo", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = AiResourceInfo.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new AiResourceInfo(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/model/invocation-usage.test.ts b/runtime/typescript/packages/core/tests/model/model/invocation-usage.test.ts index 082592231..36f920401 100644 --- a/runtime/typescript/packages/core/tests/model/model/invocation-usage.test.ts +++ b/runtime/typescript/packages/core/tests/model/model/invocation-usage.test.ts @@ -61,7 +61,9 @@ describe("InvocationUsage", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "inputTokens": 150,\n "outputTokens": 42,\n "totalTokens": 192\n}`, + ) as Record; const instance = InvocationUsage.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/model/model-info.test.ts b/runtime/typescript/packages/core/tests/model/model/model-info.test.ts index e8a11a04f..ad2687e23 100644 --- a/runtime/typescript/packages/core/tests/model/model/model-info.test.ts +++ b/runtime/typescript/packages/core/tests/model/model/model-info.test.ts @@ -65,7 +65,9 @@ describe("ModelInfo", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "gpt-4o",\n "displayName": "GPT-4o",\n "ownedBy": "openai",\n "contextWindow": 128000,\n "inputModalities": [\n "text",\n "image"\n ],\n "outputModalities": [\n "text"\n ],\n "additionalProperties": {\n "supportsStreaming": true\n }\n}`, + ) as Record; const instance = ModelInfo.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/model/model-options.test.ts b/runtime/typescript/packages/core/tests/model/model/model-options.test.ts index a96b7959b..266177ba9 100644 --- a/runtime/typescript/packages/core/tests/model/model/model-options.test.ts +++ b/runtime/typescript/packages/core/tests/model/model/model-options.test.ts @@ -85,7 +85,9 @@ describe("ModelOptions", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "frequencyPenalty": 0.5,\n "maxOutputTokens": 2048,\n "presencePenalty": 0.3,\n "seed": 42,\n "temperature": 0.7,\n "topK": 40,\n "topP": 0.9,\n "stopSequences": [\n "\\n",\n "###"\n ],\n "allowMultipleToolCalls": true,\n "additionalProperties": {\n "customProperty": "value",\n "anotherProperty": "anotherValue"\n }\n}`, + ) as Record; const instance = ModelOptions.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/model/model.test.ts b/runtime/typescript/packages/core/tests/model/model/model.test.ts index 0d71d05be..e0f30002d 100644 --- a/runtime/typescript/packages/core/tests/model/model/model.test.ts +++ b/runtime/typescript/packages/core/tests/model/model/model.test.ts @@ -71,7 +71,9 @@ describe("Model", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "gpt-35-turbo",\n "provider": "foundry",\n "apiType": "chat",\n "connection": {\n "kind": "key",\n "endpoint": "https://{your-custom-endpoint}.openai.azure.com/",\n "key": "{your-api-key}"\n },\n "options": {\n "type": "chat",\n "temperature": 0.7,\n "maxOutputTokens": 1000\n }\n}`, + ) as Record; const instance = Model.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/model/project-info.test.ts b/runtime/typescript/packages/core/tests/model/model/project-info.test.ts index e42509831..434796ca3 100644 --- a/runtime/typescript/packages/core/tests/model/model/project-info.test.ts +++ b/runtime/typescript/packages/core/tests/model/model/project-info.test.ts @@ -18,12 +18,6 @@ describe("ProjectInfo", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = ProjectInfo.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new ProjectInfo(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/model/subscription-info.test.ts b/runtime/typescript/packages/core/tests/model/model/subscription-info.test.ts index 2716fccd6..7b388e813 100644 --- a/runtime/typescript/packages/core/tests/model/model/subscription-info.test.ts +++ b/runtime/typescript/packages/core/tests/model/model/subscription-info.test.ts @@ -18,12 +18,6 @@ describe("SubscriptionInfo", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = SubscriptionInfo.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new SubscriptionInfo(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/model/token-usage.test.ts b/runtime/typescript/packages/core/tests/model/model/token-usage.test.ts index 1da6e77c7..1c013c787 100644 --- a/runtime/typescript/packages/core/tests/model/model/token-usage.test.ts +++ b/runtime/typescript/packages/core/tests/model/model/token-usage.test.ts @@ -61,7 +61,9 @@ describe("TokenUsage", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "promptTokens": 150,\n "completionTokens": 42,\n "totalTokens": 192\n}`, + ) as Record; const instance = TokenUsage.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/compaction-config.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/compaction-config.test.ts index c055d5cd7..30232be9c 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/compaction-config.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/compaction-config.test.ts @@ -57,7 +57,9 @@ describe("CompactionConfig", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "strategy": "summarize",\n "budget": 50000,\n "options": {\n "preserveSystemMessages": true\n }\n}`, + ) as Record; const instance = CompactionConfig.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/context-candidate.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/context-candidate.test.ts index 3c7d0c1d2..5dd608483 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/context-candidate.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/context-candidate.test.ts @@ -57,7 +57,9 @@ describe("ContextCandidate", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "memory:project-plan",\n "source": "memory"\n}`, + ) as Record; const instance = ContextCandidate.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/context-request.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/context-request.test.ts index 0f5a760e0..3cec19be2 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/context-request.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/context-request.test.ts @@ -18,12 +18,6 @@ describe("ContextRequest", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = ContextRequest.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new ContextRequest(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/delegated-state-reference.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/delegated-state-reference.test.ts index 592f7b3fb..6ee18fa42 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/delegated-state-reference.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/delegated-state-reference.test.ts @@ -61,7 +61,9 @@ describe("DelegatedStateReference", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "provider": "openai",\n "kind": "response",\n "id": "resp_abc123"\n}`, + ) as Record; const instance = DelegatedStateReference.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/engine-checkpoint.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/engine-checkpoint.test.ts index a252151ea..4c0cf24b5 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/engine-checkpoint.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/engine-checkpoint.test.ts @@ -19,7 +19,7 @@ describe("EngineCheckpoint", () => { describe("JSON serialization", () => { it("should load from JSON - example 1", () => { - const json = `{\n "id": "ckpt_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "runId": "run_abc123"\n}`; + const json = `{\n "id": "ckpt_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "runId": "run_abc123",\n "contextState": {}\n}`; const instance = EngineCheckpoint.fromJson(json); expect(instance).toBeDefined(); expect(instance.id).toEqual("ckpt_abc123"); @@ -29,7 +29,7 @@ describe("EngineCheckpoint", () => { }); it("should round-trip JSON - example 1", () => { - const json = `{\n "id": "ckpt_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "runId": "run_abc123"\n}`; + const json = `{\n "id": "ckpt_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "runId": "run_abc123",\n "contextState": {}\n}`; const instance = EngineCheckpoint.fromJson(json); const output = instance.toJson(); const reloaded = EngineCheckpoint.fromJson(output); @@ -42,7 +42,7 @@ describe("EngineCheckpoint", () => { describe("YAML serialization", () => { it("should load from YAML - example 1", () => { - const yaml = `id: ckpt_abc123\nsessionId: sess_abc123\nturnId: turn_abc123\nrunId: run_abc123\n`; + const yaml = `id: ckpt_abc123\nsessionId: sess_abc123\nturnId: turn_abc123\nrunId: run_abc123\ncontextState: {}\n`; const instance = EngineCheckpoint.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.id).toEqual("ckpt_abc123"); @@ -52,7 +52,7 @@ describe("EngineCheckpoint", () => { }); it("should round-trip YAML - example 1", () => { - const yaml = `id: ckpt_abc123\nsessionId: sess_abc123\nturnId: turn_abc123\nrunId: run_abc123\n`; + const yaml = `id: ckpt_abc123\nsessionId: sess_abc123\nturnId: turn_abc123\nrunId: run_abc123\ncontextState: {}\n`; const instance = EngineCheckpoint.fromYaml(yaml); const output = instance.toYaml(); const reloaded = EngineCheckpoint.fromYaml(output); @@ -65,7 +65,9 @@ describe("EngineCheckpoint", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "ckpt_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "runId": "run_abc123",\n "contextState": {}\n}`, + ) as Record; const instance = EngineCheckpoint.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/engine-event.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/engine-event.test.ts index b878cf51c..e46c8ed1a 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/engine-event.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/engine-event.test.ts @@ -69,7 +69,9 @@ describe("EngineEvent", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "evt_abc123",\n "timestamp": "2025-01-01T00:00:00Z",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "runId": "run_abc123"\n}`, + ) as Record; const instance = EngineEvent.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/engine-permission-decision.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/engine-permission-decision.test.ts index df2f95292..913da40de 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/engine-permission-decision.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/engine-permission-decision.test.ts @@ -18,12 +18,6 @@ describe("EnginePermissionDecision", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = EnginePermissionDecision.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new EnginePermissionDecision(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/final-output-policy-request.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/final-output-policy-request.test.ts index 2d3495161..b82420a82 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/final-output-policy-request.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/final-output-policy-request.test.ts @@ -18,12 +18,6 @@ describe("FinalOutputPolicyRequest", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = FinalOutputPolicyRequest.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new FinalOutputPolicyRequest(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/final-output-policy-result.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/final-output-policy-result.test.ts index d77239e9e..fee7eb6af 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/final-output-policy-result.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/final-output-policy-result.test.ts @@ -18,12 +18,6 @@ describe("FinalOutputPolicyResult", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = FinalOutputPolicyResult.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new FinalOutputPolicyResult(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/host-policy-request.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/host-policy-request.test.ts index 13fadcd94..c6adeed6f 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/host-policy-request.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/host-policy-request.test.ts @@ -18,12 +18,6 @@ describe("HostPolicyRequest", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = HostPolicyRequest.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new HostPolicyRequest(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/host-policy-result.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/host-policy-result.test.ts index 92de4e166..ca2e1cd71 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/host-policy-result.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/host-policy-result.test.ts @@ -18,12 +18,6 @@ describe("HostPolicyResult", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = HostPolicyResult.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new HostPolicyResult(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/invocation-context-decision.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/invocation-context-decision.test.ts index 0c554e484..123c7ce4f 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/invocation-context-decision.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/invocation-context-decision.test.ts @@ -57,7 +57,9 @@ describe("InvocationContextDecision", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "candidateId": "memory:project-plan",\n "reason": "included by relevance ranking"\n}`, + ) as Record; const instance = InvocationContextDecision.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/invocation-context-state.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/invocation-context-state.test.ts index 85a2c14e8..94e70bafd 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/invocation-context-state.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/invocation-context-state.test.ts @@ -18,12 +18,6 @@ describe("InvocationContextState", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = InvocationContextState.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new InvocationContextState(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/model-invocation-context-snapshot.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/model-invocation-context-snapshot.test.ts index 3962baa4b..5b561291d 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/model-invocation-context-snapshot.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/model-invocation-context-snapshot.test.ts @@ -19,7 +19,7 @@ describe("ModelInvocationContextSnapshot", () => { describe("JSON serialization", () => { it("should load from JSON - example 1", () => { - const json = `{\n "id": "context:inv_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "invocationId": "inv_abc123"\n}`; + const json = `{\n "id": "context:inv_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "invocationId": "inv_abc123",\n "contextState": {}\n}`; const instance = ModelInvocationContextSnapshot.fromJson(json); expect(instance).toBeDefined(); expect(instance.id).toEqual("context:inv_abc123"); @@ -29,7 +29,7 @@ describe("ModelInvocationContextSnapshot", () => { }); it("should round-trip JSON - example 1", () => { - const json = `{\n "id": "context:inv_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "invocationId": "inv_abc123"\n}`; + const json = `{\n "id": "context:inv_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "invocationId": "inv_abc123",\n "contextState": {}\n}`; const instance = ModelInvocationContextSnapshot.fromJson(json); const output = instance.toJson(); const reloaded = ModelInvocationContextSnapshot.fromJson(output); @@ -42,7 +42,7 @@ describe("ModelInvocationContextSnapshot", () => { describe("YAML serialization", () => { it("should load from YAML - example 1", () => { - const yaml = `id: "context:inv_abc123"\nsessionId: sess_abc123\nturnId: turn_abc123\ninvocationId: inv_abc123\n`; + const yaml = `id: "context:inv_abc123"\nsessionId: sess_abc123\nturnId: turn_abc123\ninvocationId: inv_abc123\ncontextState: {}\n`; const instance = ModelInvocationContextSnapshot.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.id).toEqual("context:inv_abc123"); @@ -52,7 +52,7 @@ describe("ModelInvocationContextSnapshot", () => { }); it("should round-trip YAML - example 1", () => { - const yaml = `id: "context:inv_abc123"\nsessionId: sess_abc123\nturnId: turn_abc123\ninvocationId: inv_abc123\n`; + const yaml = `id: "context:inv_abc123"\nsessionId: sess_abc123\nturnId: turn_abc123\ninvocationId: inv_abc123\ncontextState: {}\n`; const instance = ModelInvocationContextSnapshot.fromYaml(yaml); const output = instance.toYaml(); const reloaded = ModelInvocationContextSnapshot.fromYaml(output); @@ -65,7 +65,9 @@ describe("ModelInvocationContextSnapshot", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "context:inv_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "invocationId": "inv_abc123",\n "contextState": {}\n}`, + ) as Record; const instance = ModelInvocationContextSnapshot.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/model-invocation-request.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/model-invocation-request.test.ts index e96e6922c..16e19d187 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/model-invocation-request.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/model-invocation-request.test.ts @@ -18,12 +18,6 @@ describe("ModelInvocationRequest", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = ModelInvocationRequest.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new ModelInvocationRequest(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/model-invocation-response.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/model-invocation-response.test.ts index eb22f2d1b..9c1aa5b83 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/model-invocation-response.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/model-invocation-response.test.ts @@ -18,12 +18,6 @@ describe("ModelInvocationResponse", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = ModelInvocationResponse.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new ModelInvocationResponse(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/model-reconciliation-state.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/model-reconciliation-state.test.ts index e7d515ad8..b935f040f 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/model-reconciliation-state.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/model-reconciliation-state.test.ts @@ -19,7 +19,7 @@ describe("ModelReconciliationState", () => { describe("JSON serialization", () => { it("should load from JSON - example 1", () => { - const json = `{\n "invocationId": "inv_abc123",\n "message": "provider connection dropped after request was sent"\n}`; + const json = `{\n "invocationId": "inv_abc123",\n "message": "provider connection dropped after request was sent",\n "request": {\n "context": {\n "id": "context:inv_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "invocationId": "inv_abc123",\n "iteration": 1,\n "contextState": {}\n }\n }\n}`; const instance = ModelReconciliationState.fromJson(json); expect(instance).toBeDefined(); expect(instance.invocationId).toEqual("inv_abc123"); @@ -29,7 +29,7 @@ describe("ModelReconciliationState", () => { }); it("should round-trip JSON - example 1", () => { - const json = `{\n "invocationId": "inv_abc123",\n "message": "provider connection dropped after request was sent"\n}`; + const json = `{\n "invocationId": "inv_abc123",\n "message": "provider connection dropped after request was sent",\n "request": {\n "context": {\n "id": "context:inv_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "invocationId": "inv_abc123",\n "iteration": 1,\n "contextState": {}\n }\n }\n}`; const instance = ModelReconciliationState.fromJson(json); const output = instance.toJson(); const reloaded = ModelReconciliationState.fromJson(output); @@ -40,7 +40,7 @@ describe("ModelReconciliationState", () => { describe("YAML serialization", () => { it("should load from YAML - example 1", () => { - const yaml = `invocationId: inv_abc123\nmessage: provider connection dropped after request was sent\n`; + const yaml = `invocationId: inv_abc123\nmessage: provider connection dropped after request was sent\nrequest:\n context:\n id: "context:inv_abc123"\n sessionId: sess_abc123\n turnId: turn_abc123\n invocationId: inv_abc123\n iteration: 1\n contextState: {}\n`; const instance = ModelReconciliationState.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.invocationId).toEqual("inv_abc123"); @@ -50,7 +50,7 @@ describe("ModelReconciliationState", () => { }); it("should round-trip YAML - example 1", () => { - const yaml = `invocationId: inv_abc123\nmessage: provider connection dropped after request was sent\n`; + const yaml = `invocationId: inv_abc123\nmessage: provider connection dropped after request was sent\nrequest:\n context:\n id: "context:inv_abc123"\n sessionId: sess_abc123\n turnId: turn_abc123\n invocationId: inv_abc123\n iteration: 1\n contextState: {}\n`; const instance = ModelReconciliationState.fromYaml(yaml); const output = instance.toYaml(); const reloaded = ModelReconciliationState.fromYaml(output); @@ -61,7 +61,9 @@ describe("ModelReconciliationState", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "invocationId": "inv_abc123",\n "message": "provider connection dropped after request was sent",\n "request": {\n "context": {\n "id": "context:inv_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "invocationId": "inv_abc123",\n "iteration": 1,\n "contextState": {}\n }\n }\n}`, + ) as Record; const instance = ModelReconciliationState.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/model-tool-request.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/model-tool-request.test.ts index ce071cbce..7c71e5885 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/model-tool-request.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/model-tool-request.test.ts @@ -57,7 +57,9 @@ describe("ModelToolRequest", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "call_abc123",\n "name": "get_weather"\n}`, + ) as Record; const instance = ModelToolRequest.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/model-tool-result.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/model-tool-result.test.ts index a2a5e2e56..58dab8ff2 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/model-tool-result.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/model-tool-result.test.ts @@ -57,7 +57,9 @@ describe("ModelToolResult", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "requestId": "call_abc123",\n "name": "get_weather"\n}`, + ) as Record; const instance = ModelToolResult.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/replay-journal-record.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/replay-journal-record.test.ts index b10fb3229..39a5f2a15 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/replay-journal-record.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/replay-journal-record.test.ts @@ -18,12 +18,6 @@ describe("ReplayJournalRecord", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = ReplayJournalRecord.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new ReplayJournalRecord(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/replay-mismatch.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/replay-mismatch.test.ts index 9076fe15f..7e5d17d3e 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/replay-mismatch.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/replay-mismatch.test.ts @@ -18,12 +18,6 @@ describe("ReplayMismatch", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = ReplayMismatch.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new ReplayMismatch(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/replay-verification-request.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/replay-verification-request.test.ts index e5c215b4e..7313dac10 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/replay-verification-request.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/replay-verification-request.test.ts @@ -18,12 +18,6 @@ describe("ReplayVerificationRequest", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = ReplayVerificationRequest.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new ReplayVerificationRequest(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/replay-verification-result.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/replay-verification-result.test.ts index 1669c046a..a83f0d5fe 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/replay-verification-result.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/replay-verification-result.test.ts @@ -18,12 +18,6 @@ describe("ReplayVerificationResult", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = ReplayVerificationResult.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new ReplayVerificationResult(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/resume-context.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/resume-context.test.ts index 100360139..05006bc76 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/resume-context.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/resume-context.test.ts @@ -19,14 +19,14 @@ describe("ResumeContext", () => { describe("JSON serialization", () => { it("should load from JSON - example 1", () => { - const json = `{\n "lastJournalSequence": 12\n}`; + const json = `{\n "lastJournalSequence": 12,\n "checkpoint": {\n "id": "ckpt_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "runId": "run_abc123",\n "iteration": 1,\n "lastSequence": 1,\n "contextState": {}\n }\n}`; const instance = ResumeContext.fromJson(json); expect(instance).toBeDefined(); expect(instance.lastJournalSequence).toEqual(12); }); it("should round-trip JSON - example 1", () => { - const json = `{\n "lastJournalSequence": 12\n}`; + const json = `{\n "lastJournalSequence": 12,\n "checkpoint": {\n "id": "ckpt_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "runId": "run_abc123",\n "iteration": 1,\n "lastSequence": 1,\n "contextState": {}\n }\n}`; const instance = ResumeContext.fromJson(json); const output = instance.toJson(); const reloaded = ResumeContext.fromJson(output); @@ -38,14 +38,14 @@ describe("ResumeContext", () => { describe("YAML serialization", () => { it("should load from YAML - example 1", () => { - const yaml = `lastJournalSequence: 12\n`; + const yaml = `lastJournalSequence: 12\ncheckpoint:\n id: ckpt_abc123\n sessionId: sess_abc123\n turnId: turn_abc123\n runId: run_abc123\n iteration: 1\n lastSequence: 1\n contextState: {}\n`; const instance = ResumeContext.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.lastJournalSequence).toEqual(12); }); it("should round-trip YAML - example 1", () => { - const yaml = `lastJournalSequence: 12\n`; + const yaml = `lastJournalSequence: 12\ncheckpoint:\n id: ckpt_abc123\n sessionId: sess_abc123\n turnId: turn_abc123\n runId: run_abc123\n iteration: 1\n lastSequence: 1\n contextState: {}\n`; const instance = ResumeContext.fromYaml(yaml); const output = instance.toYaml(); const reloaded = ResumeContext.fromYaml(output); @@ -57,7 +57,9 @@ describe("ResumeContext", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "lastJournalSequence": 12,\n "checkpoint": {\n "id": "ckpt_abc123",\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "runId": "run_abc123",\n "iteration": 1,\n "lastSequence": 1,\n "contextState": {}\n }\n}`, + ) as Record; const instance = ResumeContext.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/retry-policy-request.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/retry-policy-request.test.ts index cbdbd501d..d643dea96 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/retry-policy-request.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/retry-policy-request.test.ts @@ -18,12 +18,6 @@ describe("RetryPolicyRequest", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = RetryPolicyRequest.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new RetryPolicyRequest(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/run-turn-request.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/run-turn-request.test.ts index 971385374..5dd5db689 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/run-turn-request.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/run-turn-request.test.ts @@ -57,7 +57,9 @@ describe("RunTurnRequest", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123"\n}`, + ) as Record; const instance = RunTurnRequest.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/run-turn-result.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/run-turn-result.test.ts index 6fd0e11f9..282809581 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/run-turn-result.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/run-turn-result.test.ts @@ -61,7 +61,9 @@ describe("RunTurnResult", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "iterations": 1\n}`, + ) as Record; const instance = RunTurnResult.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/turn-commit.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/turn-commit.test.ts index 9480aff71..1b9ae442b 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/turn-commit.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/turn-commit.test.ts @@ -19,7 +19,7 @@ describe("TurnCommit", () => { describe("JSON serialization", () => { it("should load from JSON - example 1", () => { - const json = `{\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123"\n}`; + const json = `{\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "contextState": {}\n}`; const instance = TurnCommit.fromJson(json); expect(instance).toBeDefined(); expect(instance.sessionId).toEqual("sess_abc123"); @@ -27,7 +27,7 @@ describe("TurnCommit", () => { }); it("should round-trip JSON - example 1", () => { - const json = `{\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123"\n}`; + const json = `{\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "contextState": {}\n}`; const instance = TurnCommit.fromJson(json); const output = instance.toJson(); const reloaded = TurnCommit.fromJson(output); @@ -38,7 +38,7 @@ describe("TurnCommit", () => { describe("YAML serialization", () => { it("should load from YAML - example 1", () => { - const yaml = `sessionId: sess_abc123\nturnId: turn_abc123\n`; + const yaml = `sessionId: sess_abc123\nturnId: turn_abc123\ncontextState: {}\n`; const instance = TurnCommit.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.sessionId).toEqual("sess_abc123"); @@ -46,7 +46,7 @@ describe("TurnCommit", () => { }); it("should round-trip YAML - example 1", () => { - const yaml = `sessionId: sess_abc123\nturnId: turn_abc123\n`; + const yaml = `sessionId: sess_abc123\nturnId: turn_abc123\ncontextState: {}\n`; const instance = TurnCommit.fromYaml(yaml); const output = instance.toYaml(); const reloaded = TurnCommit.fromYaml(output); @@ -57,7 +57,9 @@ describe("TurnCommit", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "contextState": {}\n}`, + ) as Record; const instance = TurnCommit.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/turn-engine-result.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/turn-engine-result.test.ts index 40ebde7b0..3c28aefd8 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/turn-engine-result.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/turn-engine-result.test.ts @@ -18,12 +18,6 @@ describe("TurnEngineResult", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = TurnEngineResult.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new TurnEngineResult(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/turn-model-request.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/turn-model-request.test.ts index 5f2c8b8b2..de7fd88ac 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/turn-model-request.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/turn-model-request.test.ts @@ -61,7 +61,9 @@ describe("TurnModelRequest", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "sessionId": "sess_abc123",\n "turnId": "turn_abc123",\n "iteration": 0\n}`, + ) as Record; const instance = TurnModelRequest.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/turn-model-response.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/turn-model-response.test.ts index 7a9f54d72..9832a7c02 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/turn-model-response.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/turn-model-response.test.ts @@ -18,12 +18,6 @@ describe("TurnModelResponse", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = TurnModelResponse.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new TurnModelResponse(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/pipeline/turn-options.test.ts b/runtime/typescript/packages/core/tests/model/pipeline/turn-options.test.ts index 6248d3284..c1cc5bc83 100644 --- a/runtime/typescript/packages/core/tests/model/pipeline/turn-options.test.ts +++ b/runtime/typescript/packages/core/tests/model/pipeline/turn-options.test.ts @@ -73,7 +73,9 @@ describe("TurnOptions", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "maxIterations": 10,\n "maxLlmRetries": 3,\n "contextBudget": 100000,\n "parallelToolCalls": true,\n "raw": false,\n "turn": 1,\n "compaction": {\n "strategy": "summarize"\n }\n}`, + ) as Record; const instance = TurnOptions.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/streaming/stream-options.test.ts b/runtime/typescript/packages/core/tests/model/streaming/stream-options.test.ts index 8bd71335c..8f721f459 100644 --- a/runtime/typescript/packages/core/tests/model/streaming/stream-options.test.ts +++ b/runtime/typescript/packages/core/tests/model/streaming/stream-options.test.ts @@ -53,7 +53,10 @@ describe("StreamOptions", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse(`{\n "includeUsage": true\n}`) as Record< + string, + unknown + >; const instance = StreamOptions.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/template/template.test.ts b/runtime/typescript/packages/core/tests/model/template/template.test.ts index c82a0c52d..874a03068 100644 --- a/runtime/typescript/packages/core/tests/model/template/template.test.ts +++ b/runtime/typescript/packages/core/tests/model/template/template.test.ts @@ -49,7 +49,9 @@ describe("Template", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "format": {\n "kind": "mustache"\n },\n "parser": {\n "kind": "mustache"\n }\n}`, + ) as Record; const instance = Template.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/tools/binding.test.ts b/runtime/typescript/packages/core/tests/model/tools/binding.test.ts index 86fb579cb..2dce6767b 100644 --- a/runtime/typescript/packages/core/tests/model/tools/binding.test.ts +++ b/runtime/typescript/packages/core/tests/model/tools/binding.test.ts @@ -67,7 +67,9 @@ describe("Binding", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "name": "my-tool",\n "input": "input-variable"\n}`, + ) as Record; const instance = Binding.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/tools/custom-tool.test.ts b/runtime/typescript/packages/core/tests/model/tools/custom-tool.test.ts index 950c3ba40..b0f509b38 100644 --- a/runtime/typescript/packages/core/tests/model/tools/custom-tool.test.ts +++ b/runtime/typescript/packages/core/tests/model/tools/custom-tool.test.ts @@ -49,7 +49,9 @@ describe("CustomTool", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "connection": {\n "kind": "reference"\n },\n "options": {\n "timeout": 30,\n "retries": 3\n }\n}`, + ) as Record; const instance = CustomTool.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/tools/function-tool.test.ts b/runtime/typescript/packages/core/tests/model/tools/function-tool.test.ts index a9096d0ae..62fb4db80 100644 --- a/runtime/typescript/packages/core/tests/model/tools/function-tool.test.ts +++ b/runtime/typescript/packages/core/tests/model/tools/function-tool.test.ts @@ -89,7 +89,9 @@ describe("FunctionTool", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "kind": "function",\n "parameters": {\n "firstName": {\n "kind": "string",\n "default": "Jane"\n },\n "lastName": {\n "kind": "string",\n "default": "Doe"\n },\n "question": {\n "kind": "string",\n "default": "What is the meaning of life?"\n }\n },\n "strict": true\n}`, + ) as Record; const instance = FunctionTool.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/tools/mcp-approval-mode.test.ts b/runtime/typescript/packages/core/tests/model/tools/mcp-approval-mode.test.ts index 60d508727..52f73f578 100644 --- a/runtime/typescript/packages/core/tests/model/tools/mcp-approval-mode.test.ts +++ b/runtime/typescript/packages/core/tests/model/tools/mcp-approval-mode.test.ts @@ -63,7 +63,9 @@ describe("McpApprovalMode", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "kind": "never",\n "alwaysRequireApprovalTools": [\n "operation1"\n ],\n "neverRequireApprovalTools": [\n "operation2"\n ]\n}`, + ) as Record; const instance = McpApprovalMode.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/tools/mcp-tool.test.ts b/runtime/typescript/packages/core/tests/model/tools/mcp-tool.test.ts index 53c93f416..dde9c84e4 100644 --- a/runtime/typescript/packages/core/tests/model/tools/mcp-tool.test.ts +++ b/runtime/typescript/packages/core/tests/model/tools/mcp-tool.test.ts @@ -65,7 +65,9 @@ describe("McpTool", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "kind": "mcp",\n "connection": {\n "kind": "reference"\n },\n "serverName": "My MCP Server",\n "serverDescription": "This tool allows access to MCP services.",\n "approvalMode": {\n "kind": "always"\n },\n "allowedTools": [\n "operation1",\n "operation2"\n ]\n}`, + ) as Record; const instance = McpTool.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/tools/open-api-tool.test.ts b/runtime/typescript/packages/core/tests/model/tools/open-api-tool.test.ts index a2f102109..86c4cf29e 100644 --- a/runtime/typescript/packages/core/tests/model/tools/open-api-tool.test.ts +++ b/runtime/typescript/packages/core/tests/model/tools/open-api-tool.test.ts @@ -57,7 +57,9 @@ describe("OpenApiTool", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "kind": "openapi",\n "connection": {\n "kind": "reference"\n },\n "specification": "./openapi.json"\n}`, + ) as Record; const instance = OpenApiTool.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/tools/prompty-tool.test.ts b/runtime/typescript/packages/core/tests/model/tools/prompty-tool.test.ts index d1f588460..604962948 100644 --- a/runtime/typescript/packages/core/tests/model/tools/prompty-tool.test.ts +++ b/runtime/typescript/packages/core/tests/model/tools/prompty-tool.test.ts @@ -61,7 +61,9 @@ describe("PromptyTool", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "kind": "prompty",\n "path": "./summarize.prompty",\n "mode": "single"\n}`, + ) as Record; const instance = PromptyTool.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/tools/tool-context.test.ts b/runtime/typescript/packages/core/tests/model/tools/tool-context.test.ts index 81c822f14..ec3a7b954 100644 --- a/runtime/typescript/packages/core/tests/model/tools/tool-context.test.ts +++ b/runtime/typescript/packages/core/tests/model/tools/tool-context.test.ts @@ -19,13 +19,13 @@ describe("ToolContext", () => { describe("JSON serialization", () => { it("should load from JSON - example 1", () => { - const json = `{\n "metadata": {\n "userId": "user-123"\n }\n}`; + const json = `{\n "metadata": {\n "userId": "user-123"\n },\n "messages": [\n {\n "role": "user",\n "parts": [\n {\n "kind": "text",\n "value": "Hello!"\n }\n ],\n "metadata": {\n "source": "user-input"\n }\n }\n ]\n}`; const instance = ToolContext.fromJson(json); expect(instance).toBeDefined(); }); it("should round-trip JSON - example 1", () => { - const json = `{\n "metadata": {\n "userId": "user-123"\n }\n}`; + const json = `{\n "metadata": {\n "userId": "user-123"\n },\n "messages": [\n {\n "role": "user",\n "parts": [\n {\n "kind": "text",\n "value": "Hello!"\n }\n ],\n "metadata": {\n "source": "user-input"\n }\n }\n ]\n}`; const instance = ToolContext.fromJson(json); const output = instance.toJson(); const reloaded = ToolContext.fromJson(output); @@ -34,13 +34,13 @@ describe("ToolContext", () => { describe("YAML serialization", () => { it("should load from YAML - example 1", () => { - const yaml = `metadata:\n userId: user-123\n`; + const yaml = `metadata:\n userId: user-123\nmessages:\n - role: user\n parts:\n - kind: text\n value: Hello!\n metadata:\n source: user-input\n`; const instance = ToolContext.fromYaml(yaml); expect(instance).toBeDefined(); }); it("should round-trip YAML - example 1", () => { - const yaml = `metadata:\n userId: user-123\n`; + const yaml = `metadata:\n userId: user-123\nmessages:\n - role: user\n parts:\n - kind: text\n value: Hello!\n metadata:\n source: user-input\n`; const instance = ToolContext.fromYaml(yaml); const output = instance.toYaml(); const reloaded = ToolContext.fromYaml(output); @@ -49,7 +49,9 @@ describe("ToolContext", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "metadata": {\n "userId": "user-123"\n },\n "messages": [\n {\n "role": "user",\n "parts": [\n {\n "kind": "text",\n "value": "Hello!"\n }\n ],\n "metadata": {\n "source": "user-input"\n }\n }\n ]\n}`, + ) as Record; const instance = ToolContext.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/tools/tool-dispatch-result.test.ts b/runtime/typescript/packages/core/tests/model/tools/tool-dispatch-result.test.ts index 543f7dd6b..f27938776 100644 --- a/runtime/typescript/packages/core/tests/model/tools/tool-dispatch-result.test.ts +++ b/runtime/typescript/packages/core/tests/model/tools/tool-dispatch-result.test.ts @@ -57,7 +57,9 @@ describe("ToolDispatchResult", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "toolCallId": "call_abc123",\n "name": "get_weather",\n "result": {\n "parts": [\n {\n "kind": "text",\n "value": "72°F and sunny"\n }\n ]\n }\n}`, + ) as Record; const instance = ToolDispatchResult.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/tools/tool.test.ts b/runtime/typescript/packages/core/tests/model/tools/tool.test.ts index 683dfcd58..f2f0e74f1 100644 --- a/runtime/typescript/packages/core/tests/model/tools/tool.test.ts +++ b/runtime/typescript/packages/core/tests/model/tools/tool.test.ts @@ -5,18 +5,6 @@ import { Tool } from "../../../src/model/index"; describe("Tool", () => { - describe("construction", () => { - it("should create a new instance with defaults", () => { - const instance = new Tool(); - expect(instance).toBeDefined(); - }); - - it("should create a new instance with partial initialization", () => { - const instance = new Tool({}); - expect(instance).toBeDefined(); - }); - }); - describe("JSON serialization", () => { it("should load from JSON - example 1", () => { const json = `{\n "name": "my-tool",\n "kind": "function",\n "description": "A description of the tool",\n "bindings": {\n "input": "value"\n }\n}`; diff --git a/runtime/typescript/packages/core/tests/model/tracing/trace-file.test.ts b/runtime/typescript/packages/core/tests/model/tracing/trace-file.test.ts index ac1fa22c7..c26e7f8d6 100644 --- a/runtime/typescript/packages/core/tests/model/tracing/trace-file.test.ts +++ b/runtime/typescript/packages/core/tests/model/tracing/trace-file.test.ts @@ -19,7 +19,7 @@ describe("TraceFile", () => { describe("JSON serialization", () => { it("should load from JSON - example 1", () => { - const json = `{\n "runtime": "python",\n "version": "2.0.0"\n}`; + const json = `{\n "runtime": "python",\n "version": "2.0.0",\n "trace": {\n "name": "prompty.core.pipeline.run",\n "__time": {\n "start": "2026-04-04T12:00:00Z",\n "end": "2026-04-04T12:00:01Z",\n "duration": 1000\n },\n "signature": "prompty.core.pipeline.run",\n "error": "Connection refused"\n }\n}`; const instance = TraceFile.fromJson(json); expect(instance).toBeDefined(); expect(instance.runtime).toEqual("python"); @@ -27,7 +27,7 @@ describe("TraceFile", () => { }); it("should round-trip JSON - example 1", () => { - const json = `{\n "runtime": "python",\n "version": "2.0.0"\n}`; + const json = `{\n "runtime": "python",\n "version": "2.0.0",\n "trace": {\n "name": "prompty.core.pipeline.run",\n "__time": {\n "start": "2026-04-04T12:00:00Z",\n "end": "2026-04-04T12:00:01Z",\n "duration": 1000\n },\n "signature": "prompty.core.pipeline.run",\n "error": "Connection refused"\n }\n}`; const instance = TraceFile.fromJson(json); const output = instance.toJson(); const reloaded = TraceFile.fromJson(output); @@ -38,7 +38,7 @@ describe("TraceFile", () => { describe("YAML serialization", () => { it("should load from YAML - example 1", () => { - const yaml = `runtime: python\nversion: 2.0.0\n`; + const yaml = `runtime: python\nversion: 2.0.0\ntrace:\n name: prompty.core.pipeline.run\n __time:\n start: "2026-04-04T12:00:00Z"\n end: "2026-04-04T12:00:01Z"\n duration: 1000\n signature: prompty.core.pipeline.run\n error: Connection refused\n`; const instance = TraceFile.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.runtime).toEqual("python"); @@ -46,7 +46,7 @@ describe("TraceFile", () => { }); it("should round-trip YAML - example 1", () => { - const yaml = `runtime: python\nversion: 2.0.0\n`; + const yaml = `runtime: python\nversion: 2.0.0\ntrace:\n name: prompty.core.pipeline.run\n __time:\n start: "2026-04-04T12:00:00Z"\n end: "2026-04-04T12:00:01Z"\n duration: 1000\n signature: prompty.core.pipeline.run\n error: Connection refused\n`; const instance = TraceFile.fromYaml(yaml); const output = instance.toYaml(); const reloaded = TraceFile.fromYaml(output); @@ -57,7 +57,9 @@ describe("TraceFile", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "runtime": "python",\n "version": "2.0.0",\n "trace": {\n "name": "prompty.core.pipeline.run",\n "__time": {\n "start": "2026-04-04T12:00:00Z",\n "end": "2026-04-04T12:00:01Z",\n "duration": 1000\n },\n "signature": "prompty.core.pipeline.run",\n "error": "Connection refused"\n }\n}`, + ) as Record; const instance = TraceFile.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/tracing/trace-span.test.ts b/runtime/typescript/packages/core/tests/model/tracing/trace-span.test.ts index 572a778dc..01b361fd4 100644 --- a/runtime/typescript/packages/core/tests/model/tracing/trace-span.test.ts +++ b/runtime/typescript/packages/core/tests/model/tracing/trace-span.test.ts @@ -19,7 +19,7 @@ describe("TraceSpan", () => { describe("JSON serialization", () => { it("should load from JSON - example 1", () => { - const json = `{\n "name": "prompty.core.pipeline.run",\n "signature": "prompty.core.pipeline.run",\n "error": "Connection refused"\n}`; + const json = `{\n "name": "prompty.core.pipeline.run",\n "signature": "prompty.core.pipeline.run",\n "error": "Connection refused",\n "__time": {\n "start": "2026-04-04T12:00:00Z",\n "end": "2026-04-04T12:00:01Z",\n "duration": 1000\n }\n}`; const instance = TraceSpan.fromJson(json); expect(instance).toBeDefined(); expect(instance.name).toEqual("prompty.core.pipeline.run"); @@ -28,7 +28,7 @@ describe("TraceSpan", () => { }); it("should round-trip JSON - example 1", () => { - const json = `{\n "name": "prompty.core.pipeline.run",\n "signature": "prompty.core.pipeline.run",\n "error": "Connection refused"\n}`; + const json = `{\n "name": "prompty.core.pipeline.run",\n "signature": "prompty.core.pipeline.run",\n "error": "Connection refused",\n "__time": {\n "start": "2026-04-04T12:00:00Z",\n "end": "2026-04-04T12:00:01Z",\n "duration": 1000\n }\n}`; const instance = TraceSpan.fromJson(json); const output = instance.toJson(); const reloaded = TraceSpan.fromJson(output); @@ -40,7 +40,7 @@ describe("TraceSpan", () => { describe("YAML serialization", () => { it("should load from YAML - example 1", () => { - const yaml = `name: prompty.core.pipeline.run\nsignature: prompty.core.pipeline.run\nerror: Connection refused\n`; + const yaml = `name: prompty.core.pipeline.run\nsignature: prompty.core.pipeline.run\nerror: Connection refused\n__time:\n start: "2026-04-04T12:00:00Z"\n end: "2026-04-04T12:00:01Z"\n duration: 1000\n`; const instance = TraceSpan.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.name).toEqual("prompty.core.pipeline.run"); @@ -49,7 +49,7 @@ describe("TraceSpan", () => { }); it("should round-trip YAML - example 1", () => { - const yaml = `name: prompty.core.pipeline.run\nsignature: prompty.core.pipeline.run\nerror: Connection refused\n`; + const yaml = `name: prompty.core.pipeline.run\nsignature: prompty.core.pipeline.run\nerror: Connection refused\n__time:\n start: "2026-04-04T12:00:00Z"\n end: "2026-04-04T12:00:01Z"\n duration: 1000\n`; const instance = TraceSpan.fromYaml(yaml); const output = instance.toYaml(); const reloaded = TraceSpan.fromYaml(output); @@ -61,7 +61,9 @@ describe("TraceSpan", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "name": "prompty.core.pipeline.run",\n "signature": "prompty.core.pipeline.run",\n "error": "Connection refused",\n "__time": {\n "start": "2026-04-04T12:00:00Z",\n "end": "2026-04-04T12:00:01Z",\n "duration": 1000\n }\n}`, + ) as Record; const instance = TraceSpan.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/tracing/trace-time.test.ts b/runtime/typescript/packages/core/tests/model/tracing/trace-time.test.ts index 12b14fd85..75719349e 100644 --- a/runtime/typescript/packages/core/tests/model/tracing/trace-time.test.ts +++ b/runtime/typescript/packages/core/tests/model/tracing/trace-time.test.ts @@ -61,7 +61,9 @@ describe("TraceTime", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "start": "2026-04-04T12:00:00Z",\n "end": "2026-04-04T12:00:01Z",\n "duration": 1000\n}`, + ) as Record; const instance = TraceTime.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/wire/anthropic-image-block.test.ts b/runtime/typescript/packages/core/tests/model/wire/anthropic-image-block.test.ts index 55ae9668d..a7b9abb1b 100644 --- a/runtime/typescript/packages/core/tests/model/wire/anthropic-image-block.test.ts +++ b/runtime/typescript/packages/core/tests/model/wire/anthropic-image-block.test.ts @@ -18,12 +18,6 @@ describe("AnthropicImageBlock", () => { }); describe("load and save", () => { - it("should load from dictionary", () => { - const data: Record = {}; - const instance = AnthropicImageBlock.load(data); - expect(instance).toBeDefined(); - }); - it("should save to dictionary", () => { const instance = new AnthropicImageBlock(); const data = instance.save(); diff --git a/runtime/typescript/packages/core/tests/model/wire/anthropic-image-source.test.ts b/runtime/typescript/packages/core/tests/model/wire/anthropic-image-source.test.ts index 1e66ebdf4..624b791db 100644 --- a/runtime/typescript/packages/core/tests/model/wire/anthropic-image-source.test.ts +++ b/runtime/typescript/packages/core/tests/model/wire/anthropic-image-source.test.ts @@ -57,7 +57,9 @@ describe("AnthropicImageSource", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "media_type": "image/png",\n "data": "iVBORw0KGgo..."\n}`, + ) as Record; const instance = AnthropicImageSource.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/wire/anthropic-messages-request.test.ts b/runtime/typescript/packages/core/tests/model/wire/anthropic-messages-request.test.ts index 79e8cf8e3..0b15350aa 100644 --- a/runtime/typescript/packages/core/tests/model/wire/anthropic-messages-request.test.ts +++ b/runtime/typescript/packages/core/tests/model/wire/anthropic-messages-request.test.ts @@ -19,7 +19,7 @@ describe("AnthropicMessagesRequest", () => { describe("JSON serialization", () => { it("should load from JSON - example 1", () => { - const json = `{\n "model": "claude-sonnet-4-20250514",\n "max_tokens": 4096,\n "system": "You are a helpful assistant.",\n "temperature": 0.7,\n "top_p": 0.9,\n "top_k": 40,\n "stop_sequences": [\n "\\n\\nHuman:"\n ]\n}`; + const json = `{\n "model": "claude-sonnet-4-20250514",\n "max_tokens": 4096,\n "system": "You are a helpful assistant.",\n "temperature": 0.7,\n "top_p": 0.9,\n "top_k": 40,\n "stop_sequences": [\n "\\n\\nHuman:"\n ],\n "messages": [\n {\n "role": "user",\n "content": []\n }\n ]\n}`; const instance = AnthropicMessagesRequest.fromJson(json); expect(instance).toBeDefined(); expect(instance.model).toEqual("claude-sonnet-4-20250514"); @@ -31,7 +31,7 @@ describe("AnthropicMessagesRequest", () => { }); it("should round-trip JSON - example 1", () => { - const json = `{\n "model": "claude-sonnet-4-20250514",\n "max_tokens": 4096,\n "system": "You are a helpful assistant.",\n "temperature": 0.7,\n "top_p": 0.9,\n "top_k": 40,\n "stop_sequences": [\n "\\n\\nHuman:"\n ]\n}`; + const json = `{\n "model": "claude-sonnet-4-20250514",\n "max_tokens": 4096,\n "system": "You are a helpful assistant.",\n "temperature": 0.7,\n "top_p": 0.9,\n "top_k": 40,\n "stop_sequences": [\n "\\n\\nHuman:"\n ],\n "messages": [\n {\n "role": "user",\n "content": []\n }\n ]\n}`; const instance = AnthropicMessagesRequest.fromJson(json); const output = instance.toJson(); const reloaded = AnthropicMessagesRequest.fromJson(output); @@ -46,7 +46,7 @@ describe("AnthropicMessagesRequest", () => { describe("YAML serialization", () => { it("should load from YAML - example 1", () => { - const yaml = `model: claude-sonnet-4-20250514\nmax_tokens: 4096\nsystem: You are a helpful assistant.\ntemperature: 0.7\ntop_p: 0.9\ntop_k: 40\nstop_sequences:\n - "\\n\\nHuman:"\n`; + const yaml = `model: claude-sonnet-4-20250514\nmax_tokens: 4096\nsystem: You are a helpful assistant.\ntemperature: 0.7\ntop_p: 0.9\ntop_k: 40\nstop_sequences:\n - "\\n\\nHuman:"\nmessages:\n - role: user\n content: []\n`; const instance = AnthropicMessagesRequest.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.model).toEqual("claude-sonnet-4-20250514"); @@ -58,7 +58,7 @@ describe("AnthropicMessagesRequest", () => { }); it("should round-trip YAML - example 1", () => { - const yaml = `model: claude-sonnet-4-20250514\nmax_tokens: 4096\nsystem: You are a helpful assistant.\ntemperature: 0.7\ntop_p: 0.9\ntop_k: 40\nstop_sequences:\n - "\\n\\nHuman:"\n`; + const yaml = `model: claude-sonnet-4-20250514\nmax_tokens: 4096\nsystem: You are a helpful assistant.\ntemperature: 0.7\ntop_p: 0.9\ntop_k: 40\nstop_sequences:\n - "\\n\\nHuman:"\nmessages:\n - role: user\n content: []\n`; const instance = AnthropicMessagesRequest.fromYaml(yaml); const output = instance.toYaml(); const reloaded = AnthropicMessagesRequest.fromYaml(output); @@ -73,7 +73,9 @@ describe("AnthropicMessagesRequest", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "model": "claude-sonnet-4-20250514",\n "max_tokens": 4096,\n "system": "You are a helpful assistant.",\n "temperature": 0.7,\n "top_p": 0.9,\n "top_k": 40,\n "stop_sequences": [\n "\\n\\nHuman:"\n ],\n "messages": [\n {\n "role": "user",\n "content": []\n }\n ]\n}`, + ) as Record; const instance = AnthropicMessagesRequest.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/wire/anthropic-messages-response.test.ts b/runtime/typescript/packages/core/tests/model/wire/anthropic-messages-response.test.ts index dad0b6dfd..0687e5954 100644 --- a/runtime/typescript/packages/core/tests/model/wire/anthropic-messages-response.test.ts +++ b/runtime/typescript/packages/core/tests/model/wire/anthropic-messages-response.test.ts @@ -19,7 +19,7 @@ describe("AnthropicMessagesResponse", () => { describe("JSON serialization", () => { it("should load from JSON - example 1", () => { - const json = `{\n "id": "msg_01XFDUDYJgAACzvnptvVoYEL",\n "model": "claude-sonnet-4-20250514",\n "stop_reason": "end_turn"\n}`; + const json = `{\n "id": "msg_01XFDUDYJgAACzvnptvVoYEL",\n "model": "claude-sonnet-4-20250514",\n "stop_reason": "end_turn",\n "usage": {\n "input_tokens": 150,\n "output_tokens": 42\n }\n}`; const instance = AnthropicMessagesResponse.fromJson(json); expect(instance).toBeDefined(); expect(instance.id).toEqual("msg_01XFDUDYJgAACzvnptvVoYEL"); @@ -28,7 +28,7 @@ describe("AnthropicMessagesResponse", () => { }); it("should round-trip JSON - example 1", () => { - const json = `{\n "id": "msg_01XFDUDYJgAACzvnptvVoYEL",\n "model": "claude-sonnet-4-20250514",\n "stop_reason": "end_turn"\n}`; + const json = `{\n "id": "msg_01XFDUDYJgAACzvnptvVoYEL",\n "model": "claude-sonnet-4-20250514",\n "stop_reason": "end_turn",\n "usage": {\n "input_tokens": 150,\n "output_tokens": 42\n }\n}`; const instance = AnthropicMessagesResponse.fromJson(json); const output = instance.toJson(); const reloaded = AnthropicMessagesResponse.fromJson(output); @@ -40,7 +40,7 @@ describe("AnthropicMessagesResponse", () => { describe("YAML serialization", () => { it("should load from YAML - example 1", () => { - const yaml = `id: msg_01XFDUDYJgAACzvnptvVoYEL\nmodel: claude-sonnet-4-20250514\nstop_reason: end_turn\n`; + const yaml = `id: msg_01XFDUDYJgAACzvnptvVoYEL\nmodel: claude-sonnet-4-20250514\nstop_reason: end_turn\nusage:\n input_tokens: 150\n output_tokens: 42\n`; const instance = AnthropicMessagesResponse.fromYaml(yaml); expect(instance).toBeDefined(); expect(instance.id).toEqual("msg_01XFDUDYJgAACzvnptvVoYEL"); @@ -49,7 +49,7 @@ describe("AnthropicMessagesResponse", () => { }); it("should round-trip YAML - example 1", () => { - const yaml = `id: msg_01XFDUDYJgAACzvnptvVoYEL\nmodel: claude-sonnet-4-20250514\nstop_reason: end_turn\n`; + const yaml = `id: msg_01XFDUDYJgAACzvnptvVoYEL\nmodel: claude-sonnet-4-20250514\nstop_reason: end_turn\nusage:\n input_tokens: 150\n output_tokens: 42\n`; const instance = AnthropicMessagesResponse.fromYaml(yaml); const output = instance.toYaml(); const reloaded = AnthropicMessagesResponse.fromYaml(output); @@ -61,7 +61,9 @@ describe("AnthropicMessagesResponse", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "msg_01XFDUDYJgAACzvnptvVoYEL",\n "model": "claude-sonnet-4-20250514",\n "stop_reason": "end_turn",\n "usage": {\n "input_tokens": 150,\n "output_tokens": 42\n }\n}`, + ) as Record; const instance = AnthropicMessagesResponse.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/wire/anthropic-text-block.test.ts b/runtime/typescript/packages/core/tests/model/wire/anthropic-text-block.test.ts index 3f391cbc5..13d6560d0 100644 --- a/runtime/typescript/packages/core/tests/model/wire/anthropic-text-block.test.ts +++ b/runtime/typescript/packages/core/tests/model/wire/anthropic-text-block.test.ts @@ -53,7 +53,9 @@ describe("AnthropicTextBlock", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "text": "Hello, how can I help?"\n}`, + ) as Record; const instance = AnthropicTextBlock.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/wire/anthropic-tool-definition.test.ts b/runtime/typescript/packages/core/tests/model/wire/anthropic-tool-definition.test.ts index 6b9de0777..f18ae4fe6 100644 --- a/runtime/typescript/packages/core/tests/model/wire/anthropic-tool-definition.test.ts +++ b/runtime/typescript/packages/core/tests/model/wire/anthropic-tool-definition.test.ts @@ -61,7 +61,9 @@ describe("AnthropicToolDefinition", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "name": "get_weather",\n "description": "Get the current weather for a city"\n}`, + ) as Record; const instance = AnthropicToolDefinition.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/wire/anthropic-tool-result-block.test.ts b/runtime/typescript/packages/core/tests/model/wire/anthropic-tool-result-block.test.ts index cd1c01003..6c74d9f04 100644 --- a/runtime/typescript/packages/core/tests/model/wire/anthropic-tool-result-block.test.ts +++ b/runtime/typescript/packages/core/tests/model/wire/anthropic-tool-result-block.test.ts @@ -57,7 +57,9 @@ describe("AnthropicToolResultBlock", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "tool_use_id": "toolu_01A09q90qw90lq917835lq9",\n "content": "72°F and sunny in Paris"\n}`, + ) as Record; const instance = AnthropicToolResultBlock.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/wire/anthropic-tool-use-block.test.ts b/runtime/typescript/packages/core/tests/model/wire/anthropic-tool-use-block.test.ts index cfc33b474..a01db2118 100644 --- a/runtime/typescript/packages/core/tests/model/wire/anthropic-tool-use-block.test.ts +++ b/runtime/typescript/packages/core/tests/model/wire/anthropic-tool-use-block.test.ts @@ -57,7 +57,9 @@ describe("AnthropicToolUseBlock", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "id": "toolu_01A09q90qw90lq917835lq9",\n "name": "get_weather",\n "input": {\n "city": "Paris"\n }\n}`, + ) as Record; const instance = AnthropicToolUseBlock.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/wire/anthropic-usage.test.ts b/runtime/typescript/packages/core/tests/model/wire/anthropic-usage.test.ts index 139b7c417..7e4c664dd 100644 --- a/runtime/typescript/packages/core/tests/model/wire/anthropic-usage.test.ts +++ b/runtime/typescript/packages/core/tests/model/wire/anthropic-usage.test.ts @@ -57,7 +57,9 @@ describe("AnthropicUsage", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse( + `{\n "input_tokens": 150,\n "output_tokens": 42\n}`, + ) as Record; const instance = AnthropicUsage.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/model/wire/anthropic-wire-message.test.ts b/runtime/typescript/packages/core/tests/model/wire/anthropic-wire-message.test.ts index 41cf78071..ce4c5f94c 100644 --- a/runtime/typescript/packages/core/tests/model/wire/anthropic-wire-message.test.ts +++ b/runtime/typescript/packages/core/tests/model/wire/anthropic-wire-message.test.ts @@ -53,7 +53,10 @@ describe("AnthropicWireMessage", () => { describe("load and save", () => { it("should load from dictionary", () => { - const data: Record = {}; + const data = JSON.parse(`{\n "role": "user"\n}`) as Record< + string, + unknown + >; const instance = AnthropicWireMessage.load(data); expect(instance).toBeDefined(); }); diff --git a/runtime/typescript/packages/core/tests/named-collection-vectors.test.ts b/runtime/typescript/packages/core/tests/named-collection-vectors.test.ts new file mode 100644 index 000000000..61b856d32 --- /dev/null +++ b/runtime/typescript/packages/core/tests/named-collection-vectors.test.ts @@ -0,0 +1,198 @@ +/** + * Named-collection load/save/reload contracts backed by the shared model vectors. + * + * Ported from the Rust reference suite (runtime/rust/prompty/tests/named_collection_vectors.rs) + * so the contract is executed against the TypeScript emitted models rather than assumed. + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { Prompty } from "../src/index.js"; + +type JsonValue = unknown; +type JsonObject = Record; + +interface NamedCollectionVector { + name: string; + operation: "load-save-reload" | "load-error"; + collectionPath?: string; + input: JsonObject; + expected: JsonObject; +} + +const vectorsPath = resolve( + import.meta.dirname, + "../../../../../spec/vectors/model/named_collection_vectors.json", +); + +const vectors = ( + JSON.parse(readFileSync(vectorsPath, "utf8")) as { + vectors: NamedCollectionVector[]; + } +).vectors; + +const isPlainObject = (value: JsonValue): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * Normalize either named-collection wire form into a comparable list of + * entries carrying an explicit name. + */ +const semanticEntries = (collection: JsonValue): JsonObject[] => { + if (Array.isArray(collection)) { + return collection.map((entry, index) => { + if (!isPlainObject(entry)) { + throw new Error(`array-form named collection entry ${index} must be an object`); + } + return { name: "", ...entry }; + }); + } + if (isPlainObject(collection)) { + return Object.keys(collection) + .sort() + .map((name) => { + const entry = collection[name]; + if (!isPlainObject(entry)) { + throw new Error(`object-form named collection entry ${name} must be an object`); + } + return { ...entry, name }; + }); + } + throw new Error("named collection must be an array or object"); +}; + +/** + * Every field the vector declares must be present and equal. Fields the + * vector does not mention are ignored. + */ +const assertSubset = (actual: JsonValue, expected: JsonValue, path: string): void => { + if (!isPlainObject(expected)) { + expect(actual, `${path}`).toEqual(expected); + return; + } + expect(isPlainObject(actual), `${path}: expected an object, got ${typeof actual}`).toBe(true); + const actualObject = actual as JsonObject; + for (const [key, expectedValue] of Object.entries(expected)) { + expect( + Object.prototype.hasOwnProperty.call(actualObject, key), + `${path}: missing field "${key}"`, + ).toBe(true); + assertSubset(actualObject[key], expectedValue, `${path}.${key}`); + } +}; + +const assertNamedCollection = ( + vectorName: string, + collection: JsonValue, + expected: JsonObject, +): void => { + const expectedFormat = expected.collectionFormat as string; + const actualFormat = Array.isArray(collection) ? "array" : "object"; + expect(actualFormat, `[${vectorName}] named collection wire form`).toBe(expectedFormat); + + // wireEntries assert on the raw saved payload: each is {index, absentFields}, + // requiring the entry at that position never materializes a synthetic field. + const wireEntries = expected.wireEntries as + | Array<{ index: number; absentFields?: string[] }> + | undefined; + if (wireEntries !== undefined) { + const rawEntries = collection as JsonValue[]; + for (const assertion of wireEntries) { + const entry = rawEntries[assertion.index]; + expect( + isPlainObject(entry), + `[${vectorName}] wire entry ${assertion.index} must be an object`, + ).toBe(true); + for (const field of assertion.absentFields ?? []) { + expect( + Object.prototype.hasOwnProperty.call(entry as JsonObject, field), + `[${vectorName}] wire entry ${assertion.index} unexpectedly materialized "${field}"`, + ).toBe(false); + } + } + } + + const actualEntries = semanticEntries(collection); + const expectedEntries = expected.entries as JsonObject[]; + expect(actualEntries.length, `[${vectorName}] named collection entry count`).toBe( + expectedEntries.length, + ); + + const absentEntryFields = expected.absentEntryFields as string[] | undefined; + if (absentEntryFields !== undefined) { + for (const entry of actualEntries) { + for (const field of absentEntryFields) { + expect( + Object.prototype.hasOwnProperty.call(entry, field), + `[${vectorName}] entry ${String(entry.name)} unexpectedly populated "${field}" ` + + `with ${JSON.stringify(entry[field])}`, + ).toBe(false); + } + } + } + + if (expected.preserveOrder === true) { + expectedEntries.forEach((expectedEntry, index) => { + assertSubset(actualEntries[index], expectedEntry, `${vectorName}.entries[${index}]`); + }); + return; + } + + const actualByName = new Map(actualEntries.map((entry) => [String(entry.name), entry])); + for (const expectedEntry of expectedEntries) { + const name = String(expectedEntry.name); + const actualEntry = actualByName.get(name); + expect(actualEntry, `[${vectorName}] missing named entry "${name}"`).toBeDefined(); + assertSubset(actualEntry, expectedEntry, `${vectorName}.entries.${name}`); + } +}; + +describe("named collection vectors", () => { + const roundtripVectors = vectors.filter((vector) => vector.operation === "load-save-reload"); + const rejectionVectors = vectors.filter((vector) => vector.operation === "load-error"); + + it("covers both halves of the contract", () => { + expect(roundtripVectors.length).toBeGreaterThan(0); + expect(rejectionVectors.length).toBeGreaterThan(0); + }); + + describe.each(roundtripVectors.map((vector) => [vector.name, vector] as const))( + "%s", + (_name, vector) => { + it("round-trips through load, save and reload", () => { + const collectionPath = vector.collectionPath as string; + + const loaded = Prompty.load(vector.input); + const saved = loaded.save() as JsonObject; + expect( + Object.prototype.hasOwnProperty.call(saved, collectionPath), + `[${vector.name}] missing collection "${collectionPath}" after save`, + ).toBe(true); + assertNamedCollection(vector.name, saved[collectionPath], vector.expected); + + const reloaded = Prompty.load(saved); + const resaved = reloaded.save() as JsonObject; + expect( + Object.prototype.hasOwnProperty.call(resaved, collectionPath), + `[${vector.name}] reload lost collection "${collectionPath}"`, + ).toBe(true); + assertNamedCollection(vector.name, resaved[collectionPath], vector.expected); + }); + }, + ); + + describe.each(rejectionVectors.map((vector) => [vector.name, vector] as const))( + "%s", + (_name, vector) => { + it("rejects the invalid named collection entry", () => { + expect( + () => Prompty.load(vector.input), + `[${vector.name}] expected rejection at ${String(vector.expected.path)} ` + + `(category ${String(vector.expected.valueCategory)})`, + ).toThrow(); + }); + }, + ); +}); diff --git a/runtime/typescript/packages/core/tests/property-scalar-coercion-vectors.test.ts b/runtime/typescript/packages/core/tests/property-scalar-coercion-vectors.test.ts new file mode 100644 index 000000000..92190b5c9 --- /dev/null +++ b/runtime/typescript/packages/core/tests/property-scalar-coercion-vectors.test.ts @@ -0,0 +1,55 @@ +/** + * Canonical atomic Property scalar coercion tests backed by shared model vectors. + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { Property } from "../src/index.js"; + +interface PropertyScalarCase { + name: string; + input: string | number | boolean; + expected: { + kind: string; + example: string | number | boolean; + }; +} + +interface PropertyScalarVector { + name: string; + operation: "load"; + cases: PropertyScalarCase[]; +} + +const vectorsPath = resolve( + import.meta.dirname, + "../../../../../spec/vectors/model/property_scalar_coercion_vectors.json", +); +const vector = ( + JSON.parse(readFileSync(vectorsPath, "utf8")) as { + vectors: PropertyScalarVector[]; + } +).vectors[0]; + +describe("Property scalar coercion vectors", () => { + it("coerces all primitive scalar branches atomically", () => { + expect(vector.name).toBe("all_primitive_property_scalars_coerce_atomically"); + expect(vector.operation).toBe("load"); + expect(vector.cases.map((candidate) => candidate.name)).toEqual([ + "string", + "integer", + "float", + "boolean", + ]); + + for (const scalarCase of vector.cases) { + const loaded = Property.fromJson(JSON.stringify(scalarCase.input)); + expect(loaded.kind, scalarCase.name).toBe(scalarCase.expected.kind); + expect(loaded.example, scalarCase.name).toEqual( + scalarCase.expected.example, + ); + } + }); +}); diff --git a/runtime/typescript/packages/core/tests/spec-vectors.test.ts b/runtime/typescript/packages/core/tests/spec-vectors.test.ts index fdc30b66c..0c9dce19a 100644 --- a/runtime/typescript/packages/core/tests/spec-vectors.test.ts +++ b/runtime/typescript/packages/core/tests/spec-vectors.test.ts @@ -430,11 +430,12 @@ function validateAgentFields(agent: Prompty, expected: any, vecName: string): vo } } if (et.bindings !== undefined) { - const atBindings = (at as any).bindings as Array<{name: string; input: string}>; + const atBindings = (at as FunctionTool).bindings ?? []; + const expectedBindings = Object.entries(et.bindings as Record); expect(atBindings).toBeDefined(); - expect(atBindings.length).toBeGreaterThan(0); - for (const [bk, bv] of Object.entries(et.bindings as Record)) { - const found = atBindings.find((b: any) => b.name === bk); + expect(atBindings).toHaveLength(expectedBindings.length); + for (const [bk, bv] of expectedBindings) { + const found = atBindings.find((binding) => binding.name === bk); expect(found).toBeDefined(); if (bv.input !== undefined) { expect(found!.input).toBe(bv.input); @@ -795,6 +796,10 @@ function compareWireBodies(actual: Record, expected: Record { it("concatenates multiple text parts", () => { const msg = new Message({ role: "user", parts: [ - { kind: "text", value: "Hello " }, + { kind: "text", value: "Hello" }, { kind: "text", value: "world" }, ] }); - expect(msg.text).toBe("Hello world"); + expect(msg.text).toBe("Hello\nworld"); + expect(msg.toTextContent()).toBe("Hello\nworld"); }); it("returns string for single text part in toTextContent", () => { @@ -45,6 +46,8 @@ describe("Message", () => { const msg = new Message({ role: "system" }); expect(msg.parts).toEqual([]); expect(msg.metadata).toEqual({}); + expect(msg.text).toBe(""); + expect(msg.toTextContent()).toBe(""); }); }); diff --git a/runtime/typescript/packages/foundry/src/azure-models.ts b/runtime/typescript/packages/foundry/src/azure-models.ts index d48f12558..632d58fee 100644 --- a/runtime/typescript/packages/foundry/src/azure-models.ts +++ b/runtime/typescript/packages/foundry/src/azure-models.ts @@ -30,6 +30,58 @@ interface FoundryDeploymentClient { getToken: () => Promise; } +/** + * Map one raw Azure OpenAI model-catalog entry into the provider-neutral + * `ModelInfo` contract. + * + * Exercised by the shared `spec/vectors/discovery` vectors. Mirrors + * `parse_catalog_model_object` in `runtime/rust/prompty-foundry/src/models.rs`. + */ +export function catalogModelToModelInfo(raw: Record): ModelInfo { + return new ModelInfo({ + id: typeof raw["id"] === "string" ? raw["id"] : "", + ownedBy: typeof raw["owned_by"] === "string" ? raw["owned_by"] : undefined, + contextWindow: typeof raw["maxContextLength"] === "number" ? raw["maxContextLength"] : undefined, + additionalProperties: raw, + }); +} + +/** + * Map one raw Foundry deployment into the provider-neutral `ModelInfo` contract. + * + * Foundry's data-plane `/deployments?api-version=v1` returns a flat shape + * (`modelName`, `modelPublisher`, top-level `capabilities`), while the ARM + * management-plane shape nests these under `properties.model`. Both are + * supported, matching `parse_deployment_object` in + * `runtime/rust/prompty-foundry/src/models.rs`. + */ +export function deploymentToModelInfo(raw: Record): ModelInfo { + const properties = asRecord(raw["properties"]); + const model = asRecord(properties?.["model"]); + const capabilities = asRecord(properties?.["capabilities"]) ?? asRecord(model?.["capabilities"]) ?? asRecord(raw["capabilities"]); + + return new ModelInfo({ + id: typeof raw["name"] === "string" ? raw["name"] : "", + displayName: + (typeof raw["modelName"] === "string" ? raw["modelName"] : undefined) ?? + (typeof model?.["name"] === "string" ? (model["name"] as string) : undefined), + ownedBy: + (typeof raw["modelPublisher"] === "string" ? raw["modelPublisher"] : undefined) ?? + (typeof model?.["publisher"] === "string" ? (model["publisher"] as string) : undefined) ?? + "azure", + contextWindow: + getNumber(capabilities, ["maxContextLength", "contextWindow", "context_length"]) ?? + getNumber(model, ["maxContextLength"]) ?? + getNumber(raw, ["maxContextLength"]), + inputModalities: getStringArray(capabilities, ["inputModalities", "input_modalities", "supportedInputModalities"]), + outputModalities: getStringArray(capabilities, ["outputModalities", "output_modalities", "supportedOutputModalities"]), + additionalProperties: raw, + }); +} + +const asRecord = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : undefined; + /** * List deployments available from a Foundry project, or models from an Azure OpenAI resource. * @@ -69,15 +121,7 @@ async function listAzureOpenAIModels(client: AzureOpenAI): Promise const models: ModelInfo[] = []; for (const m of page.data) { - const raw = m as unknown as Record; - models.push( - new ModelInfo({ - id: m.id, - ownedBy: m.owned_by, - // Azure may return maxContextLength in capabilities - contextWindow: typeof raw["maxContextLength"] === "number" ? raw["maxContextLength"] : undefined, - }), - ); + models.push(catalogModelToModelInfo(m as unknown as Record)); } return models; @@ -102,19 +146,7 @@ async function listFoundryDeployments( } const data = (await response.json()) as FoundryDeploymentsResponse; - return (data.value ?? []).map((deployment) => { - const capabilities = deployment.properties?.capabilities ?? deployment.properties?.model?.capabilities; - return new ModelInfo({ - id: deployment.name, - displayName: deployment.properties?.model?.name, - ownedBy: deployment.properties?.model?.publisher ?? "azure", - contextWindow: getNumber(capabilities, ["maxContextLength", "contextWindow", "context_length"]) - ?? deployment.properties?.model?.maxContextLength, - inputModalities: getStringArray(capabilities, ["inputModalities", "input_modalities", "supportedInputModalities"]), - outputModalities: getStringArray(capabilities, ["outputModalities", "output_modalities", "supportedOutputModalities"]), - additionalProperties: deployment as unknown as Record, - }); - }); + return (data.value ?? []).map((deployment) => deploymentToModelInfo(deployment as unknown as Record)); } function getNumber(source: Record | undefined, keys: string[]): number | undefined { diff --git a/runtime/typescript/packages/foundry/tests/discovery-vectors.test.ts b/runtime/typescript/packages/foundry/tests/discovery-vectors.test.ts new file mode 100644 index 000000000..ceaaa3bce --- /dev/null +++ b/runtime/typescript/packages/foundry/tests/discovery-vectors.test.ts @@ -0,0 +1,49 @@ +/** + * Model-discovery vector tests — validate the Foundry / Azure OpenAI wire → + * `ModelInfo` mapping against the shared spec vectors in + * `spec/vectors/discovery/`. + * + * Ported from the Rust reference suite + * (runtime/rust/prompty-foundry/tests/discovery_vectors.rs) so the contract is + * executed against the TypeScript emitted models rather than assumed. The + * `shape` field disambiguates the two Foundry endpoints: `deployment` entries + * go through `deploymentToModelInfo`, `catalog` entries through + * `catalogModelToModelInfo`. This test only maps (no network). + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { catalogModelToModelInfo, deploymentToModelInfo } from "../src/azure-models.js"; + +interface DiscoveryVector { + name: string; + provider: string; + shape?: string; + input: Record; + expected: Record; +} + +const vectorsPath = resolve( + import.meta.dirname, + "../../../../../spec/vectors/discovery/discovery_vectors.json", +); + +const vectors = ( + JSON.parse(readFileSync(vectorsPath, "utf8")) as { vectors: DiscoveryVector[] } +).vectors.filter((v) => v.provider === "foundry"); + +describe("Foundry discovery vectors", () => { + it("exercises at least one vector", () => { + expect(vectors.length).toBeGreaterThan(0); + }); + + for (const vector of vectors) { + it(`maps ${vector.name} to the canonical ModelInfo shape`, () => { + const map = vector.shape === "catalog" ? catalogModelToModelInfo : deploymentToModelInfo; + const actual = map(vector.input).save(); + expect(actual).toEqual(vector.expected); + }); + } +}); diff --git a/runtime/typescript/packages/foundry/tests/models.test.ts b/runtime/typescript/packages/foundry/tests/models.test.ts index 9e4be95e7..4351bc9ae 100644 --- a/runtime/typescript/packages/foundry/tests/models.test.ts +++ b/runtime/typescript/packages/foundry/tests/models.test.ts @@ -59,9 +59,12 @@ describe("listAzureModels", () => { it("does not set modalities (Azure API does not return them)", async () => { const models = await listAzureModels(connection); for (const m of models) { - // ModelInfo defaults modalities to [] when not explicitly set - expect(m.inputModalities).toEqual([]); - expect(m.outputModalities).toEqual([]); + // ModelInfo declares `inputModalities?: string[]` with no default + // (schema/model/discovery.tsp:43,47), and the discovery vectors + // `foundry_deployment_flat_v1` / `foundry_catalog_model` expect both + // fields ABSENT when the provider response omits them. + expect(m.inputModalities).toBeUndefined(); + expect(m.outputModalities).toBeUndefined(); } }); diff --git a/runtime/typescript/packages/openai/src/models.ts b/runtime/typescript/packages/openai/src/models.ts index c42fb5eab..bac210707 100644 --- a/runtime/typescript/packages/openai/src/models.ts +++ b/runtime/typescript/packages/openai/src/models.ts @@ -20,6 +20,31 @@ const KNOWN_MODELS: Record): ModelInfo { + const known = typeof raw["id"] === "string" ? findKnownModel(raw["id"]) : undefined; + return new ModelInfo({ + id: typeof raw["id"] === "string" ? raw["id"] : "", + ownedBy: typeof raw["owned_by"] === "string" ? raw["owned_by"] : undefined, + contextWindow: known?.contextWindow, + inputModalities: known?.inputModalities, + outputModalities: known?.outputModalities, + additionalProperties: raw, + }); +} + /** * List models available from the OpenAI API. * @@ -32,16 +57,7 @@ export async function listModels(connection: Connection): Promise { const models: ModelInfo[] = []; for (const m of page.data) { - const known = findKnownModel(m.id); - models.push( - new ModelInfo({ - id: m.id, - ownedBy: m.owned_by, - contextWindow: known?.contextWindow, - inputModalities: known?.inputModalities, - outputModalities: known?.outputModalities, - }), - ); + models.push(modelInfoFromWire(m as unknown as Record)); } return models; diff --git a/runtime/typescript/packages/openai/src/wire.ts b/runtime/typescript/packages/openai/src/wire.ts index d8a46db2d..6b2129385 100644 --- a/runtime/typescript/packages/openai/src/wire.ts +++ b/runtime/typescript/packages/openai/src/wire.ts @@ -288,28 +288,22 @@ function propertyToJsonSchema(prop: { if (prop.description) schema.description = prop.description; if (prop.enumValues && prop.enumValues.length > 0) schema.enum = prop.enumValues; - // Array items - if (prop.kind === "array") { - schema.items = prop.items - ? propertyToJsonSchema(prop.items as typeof prop, false, strict) - : { type: "string" }; - } - - // Nested object - if (prop.kind === "object") { - if (prop.properties) { - const nested: Record = {}; - const req: string[] = []; - for (const p of prop.properties as Array<{ name?: string } & typeof prop>) { - if (!p.name) continue; - nested[p.name] = propertyToJsonSchema(p, strict && !p.required, strict); - if (strict || p.required) req.push(p.name); - } - schema.properties = nested; - if (req.length > 0) schema.required = req; - } else { - schema.properties = {}; + // Array items — bare {"type": "array"} when items is unspecified + if (prop.kind === "array" && prop.items) { + schema.items = propertyToJsonSchema(prop.items as typeof prop, false, strict); + } + + // Nested object — bare {"type": "object"} when properties is empty or absent + if (prop.kind === "object" && prop.properties && prop.properties.length > 0) { + const nested: Record = {}; + const req: string[] = []; + for (const p of prop.properties as Array<{ name?: string } & typeof prop>) { + if (!p.name) continue; + nested[p.name] = propertyToJsonSchema(p, strict && !p.required, strict); + if (strict || p.required) req.push(p.name); } + schema.properties = nested; + if (req.length > 0) schema.required = req; schema.additionalProperties = false; } diff --git a/runtime/typescript/packages/openai/tests/discovery-vectors.test.ts b/runtime/typescript/packages/openai/tests/discovery-vectors.test.ts new file mode 100644 index 000000000..e3438a3d5 --- /dev/null +++ b/runtime/typescript/packages/openai/tests/discovery-vectors.test.ts @@ -0,0 +1,47 @@ +/** + * Model-discovery vector tests — validate the OpenAI wire → `ModelInfo` + * mapping against the shared spec vectors in `spec/vectors/discovery/`. + * + * Ported from the Rust reference suite + * (runtime/rust/prompty-openai/tests/discovery_vectors.rs) so the contract is + * executed against the TypeScript emitted models rather than assumed. The same + * fixture file is consumed by every runtime so all providers converge on one + * canonical `ModelInfo` shape. This test only maps (no network), so it + * exercises `modelInfoFromWire` directly. + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { modelInfoFromWire } from "../src/models.js"; + +interface DiscoveryVector { + name: string; + provider: string; + shape?: string; + input: Record; + expected: Record; +} + +const vectorsPath = resolve( + import.meta.dirname, + "../../../../../spec/vectors/discovery/discovery_vectors.json", +); + +const vectors = ( + JSON.parse(readFileSync(vectorsPath, "utf8")) as { vectors: DiscoveryVector[] } +).vectors.filter((v) => v.provider === "openai"); + +describe("OpenAI discovery vectors", () => { + it("exercises at least one vector", () => { + expect(vectors.length).toBeGreaterThan(0); + }); + + for (const vector of vectors) { + it(`maps ${vector.name} to the canonical ModelInfo shape`, () => { + const actual = modelInfoFromWire(vector.input).save(); + expect(actual).toEqual(vector.expected); + }); + } +}); diff --git a/runtime/typescript/packages/openai/tests/models.test.ts b/runtime/typescript/packages/openai/tests/models.test.ts index 222edc281..075041180 100644 --- a/runtime/typescript/packages/openai/tests/models.test.ts +++ b/runtime/typescript/packages/openai/tests/models.test.ts @@ -73,9 +73,12 @@ describe("listModels (OpenAI)", () => { const custom = models.find((m) => m.id === "ft:gpt-4o:my-org:custom:abc123")!; expect(custom.ownedBy).toBe("user-org"); expect(custom.contextWindow).toBeUndefined(); - // ModelInfo defaults modalities to [] when not provided - expect(custom.inputModalities).toEqual([]); - expect(custom.outputModalities).toEqual([]); + // ModelInfo declares `inputModalities?: string[]` with no default + // (schema/model/discovery.tsp:43,47), and the discovery vectors + // `openai_model_basic` / `openai_model_finetune_no_owner` expect both + // fields ABSENT when the provider response omits them. + expect(custom.inputModalities).toBeUndefined(); + expect(custom.outputModalities).toBeUndefined(); }); it("throws for unsupported connection kind", async () => { diff --git a/runtime/typescript/packages/openai/tests/wire-vectors.test.ts b/runtime/typescript/packages/openai/tests/wire-vectors.test.ts new file mode 100644 index 000000000..9dec6f768 --- /dev/null +++ b/runtime/typescript/packages/openai/tests/wire-vectors.test.ts @@ -0,0 +1,123 @@ +/** + * Wire format vector tests — validate against shared spec vectors. + * + * Reads `spec/vectors/wire/wire_vectors.json` and asserts that this package's + * request-body construction matches the canonical expectation for every + * OpenAI-provider vector. + * + * Ported from the Rust reference implementation at + * `runtime/rust/prompty-openai/tests/wire_vectors.rs`. + * + * Selection is data-driven rather than a hand-maintained list of names, so a + * newly added OpenAI vector is executed automatically instead of silently going + * unported. The count assertion below is the guard against the opposite failure + * — a vector disappearing without anyone noticing. + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { Message, Prompty } from "@prompty/core"; +import type { ContentPart } from "@prompty/core"; +import { describe, expect, it } from "vitest"; + +import { buildChatArgs, buildEmbeddingArgs, buildImageArgs, buildResponsesArgs } from "../src/wire.js"; + +interface WireVector { + name: string; + description: string; + input: { + provider?: string; + apiType?: string; + model_id?: string; + messages: { role: string; content: { kind: string; value: string; mediaType?: string }[] }[]; + tools?: unknown[]; + options?: Record; + outputs?: unknown[]; + }; + expected: { request_body: Record }; +} + +const VECTOR_PATH = resolve(import.meta.dirname, "../../../../../spec/vectors/wire/wire_vectors.json"); + +const allVectors: WireVector[] = JSON.parse(readFileSync(VECTOR_PATH, "utf8")); +const vectors = allVectors.filter((v) => (v.input.provider ?? "openai") === "openai"); + +/** Build Message objects from the vector's message/content description. */ +function buildMessages(input: WireVector["input"]): Message[] { + return input.messages.map((m) => { + const parts: ContentPart[] = m.content.map((p) => { + switch (p.kind) { + case "text": + return { kind: "text", value: p.value } as ContentPart; + case "image": + return { kind: "image", source: p.value, ...(p.mediaType && { mediaType: p.mediaType }) } as ContentPart; + case "audio": + return { kind: "audio", source: p.value, ...(p.mediaType && { mediaType: p.mediaType }) } as ContentPart; + default: + throw new Error(`Unknown content kind: ${p.kind}`); + } + }); + return new Message({ role: m.role as Message["role"], parts }); + }); +} + +/** Build a Prompty agent from the vector's model/tools/options/outputs fields. */ +function buildAgent(input: WireVector["input"]): Prompty { + const data: Record = { + name: "test", + kind: "prompt", + model: { + id: input.model_id ?? "gpt-4", + apiType: input.apiType ?? "chat", + provider: input.provider ?? "openai", + }, + instructions: "test", + }; + + if (input.options && Object.keys(input.options).length > 0) { + (data.model as Record).options = input.options; + } + if (input.tools && input.tools.length > 0) { + data.tools = input.tools; + } + if (input.outputs && input.outputs.length > 0) { + data.outputs = input.outputs; + } + + return Prompty.load(data); +} + +function buildRequest(vector: WireVector): Record { + const agent = buildAgent(vector.input); + const messages = buildMessages(vector.input); + + switch (vector.input.apiType ?? "chat") { + case "chat": + case "agent": + return buildChatArgs(agent, messages); + case "responses": + return buildResponsesArgs(agent, messages); + case "embedding": + return buildEmbeddingArgs(agent, messages); + case "image": + return buildImageArgs(agent, messages); + default: + throw new Error(`Unknown apiType: ${vector.input.apiType}`); + } +} + +describe("wire vectors (openai)", () => { + it("executes every OpenAI vector in the shared spec file", () => { + // Guards against a vector being removed, or the provider filter silently + // matching nothing, either of which would make this suite vacuously green. + expect(vectors.length).toBe(23); + expect(allVectors.length).toBe(29); + }); + + for (const vector of vectors) { + it(`${vector.name} — ${vector.description}`, () => { + expect(buildRequest(vector)).toEqual(vector.expected.request_body); + }); + } +}); diff --git a/schema/README.md b/schema/README.md index 8d217526d..b1360c766 100644 --- a/schema/README.md +++ b/schema/README.md @@ -39,6 +39,7 @@ npm run build ``` This generates code into: + - `runtime/typescript/packages/core/src/model/` — TypeScript - `runtime/python/prompty/prompty/model/` — Python - `runtime/csharp/Prompty.Core/Model/` — C# @@ -52,6 +53,7 @@ This generates code into: Generated files are **committed to the repo**. The generator is a dev-time tool — consumers don't need TypeSpec installed. Generated files have a header: + ``` // WARNING: This is an auto-generated file. DO NOT EDIT THIS FILE DIRECTLY ``` @@ -78,6 +80,9 @@ The `scripts/` folder holds the supporting Node helpers: output deterministic — it normalizes the generation timestamp in the Typra manifest, collapses empty generated Python test files, and trims trailing whitespace in generated Go files. +- `verify-engine-ports.mjs` validates the canonical engine-port metadata and + native cancellation seams against `spec/vectors/engine/port_contracts.json`, + and protects the deterministic legacy `pipeline/harness.tsp` contract. - `verify-typra.mjs` backs `npm run verify:typra`, which compares the current Typra export surfaces, manifest, hydration seams, and JSON AST against the committed `HEAD` baseline to detect schema drift. diff --git a/schema/model/agent/agent.tsp b/schema/model/agent/agent.tsp index 0a7094e12..5dae06701 100644 --- a/schema/model/agent/agent.tsp +++ b/schema/model/agent/agent.tsp @@ -45,7 +45,7 @@ model Prompty { }) description?: string = ""; - @doc("Additional metadata including authors, tags, and other arbitrary properties") + @doc("Additional metadata including authors, tags, and other arbitrary properties. Values may be explicit null.") @sample(#{ metadata: #{ authors: #["sethjuarez", "jietong"], @@ -108,7 +108,7 @@ model Prompty { }, }, }) - `model`: Model | string; + `model`?: Model | string; // Tools @doc("Tools available for extended functionality") diff --git a/schema/model/connection/connection.tsp b/schema/model/connection/connection.tsp index e74898086..66fb8d73f 100644 --- a/schema/model/connection/connection.tsp +++ b/schema/model/connection/connection.tsp @@ -3,13 +3,6 @@ import "../core/core.tsp"; namespace Prompty; -alias ConnectionType = - | "remote" - | "reference" - | "key" - | "anonymous" - | "foundry" - | "oauth"; alias AuthenticationMode = "user" | "system"; /** @@ -22,7 +15,7 @@ alias AuthenticationMode = "user" | "system"; model Connection { @doc("The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens)") @sample(#{ kind: "reference" }) - kind: ConnectionType; + kind: string; @doc("The authority level for the connection, indicating under whose authority the connection is made (e.g., 'user', 'agent', 'system')") @sample(#{ authenticationMode: "system" }) diff --git a/schema/model/conversation/message.tsp b/schema/model/conversation/message.tsp index ae8fdf083..38855c072 100644 --- a/schema/model/conversation/message.tsp +++ b/schema/model/conversation/message.tsp @@ -42,7 +42,7 @@ model Message { @sample(#{ parts: #[#{ kind: "text", value: "Hello!" }] }) parts: ContentPart[]; - @doc("Optional metadata associated with the message") + @doc("Optional metadata associated with the message. Values may be explicit null.") @sample(#{ metadata: #{ source: "user-input" } }) metadata: Record = #{}; } diff --git a/schema/model/core/properties.tsp b/schema/model/core/properties.tsp index 7b078c2f5..87269a149 100644 --- a/schema/model/core/properties.tsp +++ b/schema/model/core/properties.tsp @@ -12,6 +12,7 @@ namespace Prompty; * and processed to generate prompts for AI models. */ @discriminator("kind") +@entryShorthand("default") @coerce( string, #{ kind: "string", example: "{value}" }, @@ -76,7 +77,7 @@ model ArrayProperty extends Property { @doc("The type of items contained in the array") @sample(#{ items: #{ kind: "string" } }) - items: Property | Named< + items?: Property | Named< Property, "Name of the property", #{ name: "my-property" } diff --git a/schema/model/events/payloads.tsp b/schema/model/events/payloads.tsp index ef113549d..5a44c543a 100644 --- a/schema/model/events/payloads.tsp +++ b/schema/model/events/payloads.tsp @@ -87,7 +87,7 @@ model TurnEvent { @sample(#{ spanId: "span_tool_001" }) spanId?: string; - @doc("Event-specific payload. Use the typed payload model matching 'type'.") + @doc("Event-specific payload. Values may be explicit null. Use the typed payload model matching 'type'.") payload: Record = #{}; } @@ -468,7 +468,7 @@ model HostToolRequest { @sample(#{ toolName: "powershell" }) toolName: string; - @doc("Tool arguments after host-side sanitization") + @doc("Tool arguments after host-side sanitization. Values may be explicit null.") arguments?: Record; @doc("Working directory or execution scope for the tool") diff --git a/schema/model/events/session.tsp b/schema/model/events/session.tsp index c502d9680..8cccc77f8 100644 --- a/schema/model/events/session.tsp +++ b/schema/model/events/session.tsp @@ -157,7 +157,7 @@ model SessionEvent { @sample(#{ spanId: "span_hook_001" }) spanId?: string; - @doc("Event-specific payload. Use the typed payload model matching 'type'.") + @doc("Event-specific payload. Values may be explicit null. Use the typed payload model matching 'type'.") payload: Record = #{}; @doc("Redaction state for sensitive payload fields") diff --git a/schema/model/main.tsp b/schema/model/main.tsp index 591d08c54..7228f7d57 100644 --- a/schema/model/main.tsp +++ b/schema/model/main.tsp @@ -27,6 +27,7 @@ import "./pipeline/invocation.tsp"; import "./pipeline/engine-events.tsp"; import "./pipeline/checkpoint.tsp"; import "./pipeline/turn-engine.tsp"; +import "./pipeline/engine-ports.tsp"; import "./pipeline/policy.tsp"; import "./pipeline/context-planning.tsp"; import "./pipeline/turn.tsp"; diff --git a/schema/model/model/discovery.tsp b/schema/model/model/discovery.tsp index 118716343..78f4b1fc0 100644 --- a/schema/model/model/discovery.tsp +++ b/schema/model/model/discovery.tsp @@ -46,7 +46,7 @@ model ModelInfo { @sample(#{ outputModalities: #["text"] }) outputModalities?: string[]; - @doc("Additional provider-specific properties") + @doc("Additional provider-specific properties. Values may be explicit null.") @sample(#{ additionalProperties: #{ supportsStreaming: true } }) additionalProperties?: Record; } diff --git a/schema/model/pipeline/engine-ports.tsp b/schema/model/pipeline/engine-ports.tsp new file mode 100644 index 000000000..0d1706a95 --- /dev/null +++ b/schema/model/pipeline/engine-ports.tsp @@ -0,0 +1,71 @@ +import "@typra/emitter"; +import "./checkpoint.tsp"; +import "./engine-events.tsp"; +import "./invocation.tsp"; +import "./turn-engine.tsp"; + +namespace Prompty; + +@@protocol(EnginePermissionPort); +@@method(EnginePermissionPort, + "authorize", + "EnginePermissionDecision", + "Authorize one model-requested tool before execution", + #{ request: "ModelToolRequest" }, + false, + false, + #{ runtimeCancellable: true } +); + +/** Authorizes model-requested tools at a runtime cancellation boundary. */ +model EnginePermissionPort {} + +@@protocol(EngineToolPort); +@@method(EngineToolPort, + "execute", + "ModelToolResult", + "Execute one authorized model-requested tool", + #{ request: "ModelToolRequest" }, + false, + false, + #{ runtimeCancellable: true } +); + +/** Executes authorized model-requested tools at a runtime cancellation boundary. */ +model EngineToolPort {} + +@@protocol(EngineDurabilityPort); +@@method(EngineDurabilityPort, + "append", + "void", + "Append one semantic engine event durably", + #{ event: "EngineEvent" }, + false, + false +); +@@method(EngineDurabilityPort, + "appendWithCheckpoint", + "void", + "Atomically append semantic engine events and persist the checkpoint that reflects them", + #{ events: "EngineEvent[]", checkpoint: "EngineCheckpoint" }, + false, + false, + #{ atomic: true } +); + +/** Persists semantic engine events and checkpoints without runtime cancellation. */ +model EngineDurabilityPort {} + +@@protocol(EnginePostCommitPort); +@@method(EnginePostCommitPort, + "afterCommit", + "void", + "Run one idempotent host effect after the turn is durably committed", + #{ effectId: "string", commit: "TurnCommit" }, + false, + false, + #{ runtimeCancellable: true, nonFatal: true } +); + +/** Runs non-fatal host effects after a turn is durably committed. */ +model EnginePostCommitPort {} diff --git a/schema/model/pipeline/executor.tsp b/schema/model/pipeline/executor.tsp index 357a09cb7..0780d7237 100644 --- a/schema/model/pipeline/executor.tsp +++ b/schema/model/pipeline/executor.tsp @@ -10,14 +10,19 @@ namespace Prompty; "execute", "unknown", "Call an LLM provider with messages and return the raw response", - #{ agent: "Prompty", messages: "Message[]" } + #{ agent: "Prompty", messages: "Message[]" }, + false, + false, + #{ runtimeCancellable: true } ); @@method(Executor, "executeStream", "unknown", "Call an LLM provider and return a streaming response. Returns a language-specific async iterable/stream of raw chunks. Not all providers support streaming; the default implementation should signal lack of support.", #{ agent: "Prompty", messages: "Message[]" }, - true + true, + false, + #{ runtimeCancellable: true } ); @@method(Executor, "formatToolMessages", diff --git a/schema/model/pipeline/turn.tsp b/schema/model/pipeline/turn.tsp index 1a260b7f4..5d795a4b8 100644 --- a/schema/model/pipeline/turn.tsp +++ b/schema/model/pipeline/turn.tsp @@ -81,7 +81,7 @@ model TurnModelRequest { @sample(#{ iteration: 0 }) iteration: int32; - @doc("Inputs supplied to the deterministic single-turn run") + @doc("Inputs supplied to the deterministic single-turn run. Values may be explicit null.") inputs?: Record = #{}; @doc("Canonical turn execution options") @@ -104,7 +104,7 @@ model TurnModelResponse { @doc("Host tool execution requests emitted by the model callback") toolRequests?: HostToolRequest[] = #[]; - @doc("Additional deterministic state to merge into the iteration checkpoint") + @doc("Additional deterministic state to merge into the iteration checkpoint. Values may be explicit null.") checkpointState?: Record = #{}; } @@ -120,7 +120,7 @@ model RunTurnRequest { @sample(#{ turnId: "turn_abc123" }) turnId: string; - @doc("Inputs supplied to the deterministic single-turn run") + @doc("Inputs supplied to the deterministic single-turn run. Values may be explicit null.") inputs?: Record = #{}; @doc("Canonical turn execution options") diff --git a/schema/model/tools/mcp.tsp b/schema/model/tools/mcp.tsp index 419d08d67..6e9f6661e 100644 --- a/schema/model/tools/mcp.tsp +++ b/schema/model/tools/mcp.tsp @@ -26,7 +26,7 @@ model McpTool extends Tool { @doc("The approval mode for the MCP tool") @sample(#{ approvalMode: #{ kind: "always" } }) - approvalMode: McpApprovalMode; + approvalMode?: McpApprovalMode; @doc("List of allowed operations or resources for the MCP tool") @sample(#{ allowedTools: #["operation1", "operation2"] }) diff --git a/schema/package-lock.json b/schema/package-lock.json index 719a4b18b..8c1d51ca6 100644 --- a/schema/package-lock.json +++ b/schema/package-lock.json @@ -8,7 +8,7 @@ "dependencies": { "@typespec/compiler": "1.10.0", "@typespec/json-schema": "1.10.0", - "@typra/emitter": "0.4.2" + "@typra/emitter": "0.4.30" } }, "node_modules/@babel/code-frame": { @@ -476,9 +476,9 @@ } }, "node_modules/@typra/emitter": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@typra/emitter/-/emitter-0.4.2.tgz", - "integrity": "sha512-6eC3tOWiU00Qlt/LuOiISbDby76fv6lPrp9gB8wQ7q3ivUchIVWZAMzgnizqD4TIkfwQbDLMkf/oe08uG+2YZA==", + "version": "0.4.30", + "resolved": "https://registry.npmjs.org/@typra/emitter/-/emitter-0.4.30.tgz", + "integrity": "sha512-CFSPFc1/0eM2AKwU35GPEPCC0QP8kKyufmc3bGipKxqHwFpTRzQ6Fss+7hiA/LWpum8fHDj4xz180dhxvcpatg==", "license": "MIT", "dependencies": { "xml-formatter": "^3.6.7", diff --git a/schema/package.json b/schema/package.json index a0d593116..302f75510 100644 --- a/schema/package.json +++ b/schema/package.json @@ -7,12 +7,13 @@ "format:tsp:check": "npx tsp format \"model/**/*.tsp\" --check", "format:rust": "cargo fmt --all --manifest-path ../runtime/rust/prompty/Cargo.toml", "generate": "npx tsp compile model/main.tsp --config tspconfig.yaml && node scripts/normalize-typra-output.mjs", + "verify:engine-ports": "node scripts/verify-engine-ports.mjs", "verify:typra": "node scripts/verify-typra.mjs", - "build": "npm run format:tsp && npm run generate && npm run format:rust" + "build": "npm run format:tsp && npm run generate && npm run format:rust && npm run verify:engine-ports" }, "dependencies": { "@typespec/compiler": "1.10.0", "@typespec/json-schema": "1.10.0", - "@typra/emitter": "0.4.2" + "@typra/emitter": "0.4.30" } } diff --git a/schema/scripts/normalize-typra-output.mjs b/schema/scripts/normalize-typra-output.mjs index 98544872d..7c64a23ae 100644 --- a/schema/scripts/normalize-typra-output.mjs +++ b/schema/scripts/normalize-typra-output.mjs @@ -1,3 +1,4 @@ +import { spawnSync } from "node:child_process"; import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -12,6 +13,29 @@ if (existsSync(manifestPath)) { trimEmptyPythonGeneratedTests(join("..", "runtime", "python", "prompty", "tests", "model")); trimTrailingWhitespace(join("..", "runtime", "go", "prompty", "model")); +formatRust(join("..", "runtime", "rust")); + +// The Rust emitter does not format its output, but the committed tree is +// rustfmt-formatted. Without this step a clean regeneration reports ~291 +// modified files that are purely line-joining and trailing commas, which +// hides real diffs. Scoped to -p prompty: the workspace also contains +// handwritten provider crates that this script has no business reformatting. +function formatRust(root) { + if (!existsSync(root)) { + return; + } + const result = spawnSync("cargo", ["fmt", "-p", "prompty"], { + cwd: root, + stdio: "inherit", + shell: process.platform === "win32", + }); + if (result.error || result.status !== 0) { + console.warn( + "[normalize] cargo fmt -p prompty did not run; generated Rust is unformatted " + + "and will show a large spurious diff. Install a Rust toolchain and re-run.", + ); + } +} function trimEmptyPythonGeneratedTests(root) { if (!existsSync(root)) { diff --git a/schema/scripts/verify-engine-ports.mjs b/schema/scripts/verify-engine-ports.mjs new file mode 100644 index 000000000..b75198d68 --- /dev/null +++ b/schema/scripts/verify-engine-ports.mjs @@ -0,0 +1,664 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const EXPECTED_TARGETS = [ + "csharp", + "go", + "markdown", + "python", + "rust", + "typescript", +]; +const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", +}).trim(); +const vector = readJson( + join(repoRoot, "spec", "vectors", "engine", "port_contracts.json"), +); +const surfaces = readJson( + join( + repoRoot, + "schema", + "tsp-output", + ".typra-generated", + "export-surfaces.json", + ), +); + +verifyLegacyHarness(); +verifyExportSurfaces(); +verifyNoWireCancellation(); +verifyNativeSignatures(); +verifyMarkdownSemantics(); + +console.log("Canonical engine port contracts verified."); + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function verifyLegacyHarness() { + const harness = readFileSync( + join(repoRoot, "schema", "model", "pipeline", "harness.tsp"), + ); + const actual = createHash("sha256").update(harness).digest("hex"); + assertEqual( + actual, + vector.legacyHarnessSha256, + "pipeline/harness.tsp SHA-256", + ); +} + +function verifyExportSurfaces() { + const targetNames = surfaces.targets.map((target) => target.target); + assertEqual( + new Set(targetNames).size, + targetNames.length, + "Typra targets: duplicate names are not allowed", + ); + assertEqual( + JSON.stringify(targetNames.sort()), + JSON.stringify(EXPECTED_TARGETS), + "Typra targets", + ); + + for (const target of surfaces.targets) { + assertUniqueNames(target.protocols, `${target.target}: protocols`); + + for (const nativeError of vector.nativeErrors) { + assert( + !JSON.stringify(target).includes(`"${nativeError}"`), + `${target.target}: ${nativeError} must not appear in generated exports`, + ); + } + + for (const [protocolName, expectedProtocol] of Object.entries( + vector.protocols, + )) { + const protocol = target.protocols.find( + (candidate) => candidate.name === protocolName, + ); + assert(protocol, `${target.target}: missing ${protocolName} protocol`); + assertUniqueNames( + protocol.methods, + `${target.target}: ${protocolName} methods`, + ); + assertExactKeys( + Object.fromEntries( + protocol.methods.map((method) => [method.name, true]), + ), + Object.fromEntries( + Object.keys(expectedProtocol.methods).map((methodName) => [ + methodName, + true, + ]), + ), + `${target.target}: ${protocolName} methods`, + ); + + for (const [methodName, expectedMethod] of Object.entries( + expectedProtocol.methods, + )) { + const method = protocol.methods.find( + (candidate) => candidate.name === methodName, + ); + assert( + method, + `${target.target}: missing ${protocolName}.${methodName}`, + ); + assertExactKeys( + method, + Object.fromEntries( + ["name", ...Object.keys(expectedMethod)].map((key) => [key, true]), + ), + `${target.target}: ${protocolName}.${methodName} metadata`, + ); + assertSubset( + method, + expectedMethod, + `${target.target}: ${protocolName}.${methodName}`, + ); + if (expectedMethod.params) { + assertExactKeys( + method.params, + expectedMethod.params, + `${target.target}: ${protocolName}.${methodName} params`, + ); + } + + for (const syntheticName of [ + "cancellation", + "cancellationToken", + "ctx", + "signal", + ]) { + assert( + !Object.hasOwn(method.params, syntheticName), + `${target.target}: ${protocolName}.${methodName} leaked runtime cancellation into schema params`, + ); + } + } + } + } +} + +function verifyNoWireCancellation() { + const schemaRoot = join(repoRoot, "vscode", "prompty", "schemas"); + const forbiddenFields = ["ctx", "runtimeCancellable", "atomic", "nonFatal"]; + + for (const file of collectFiles(schemaRoot)) { + const content = readFileSync(file, "utf8"); + for (const field of forbiddenFields) { + const propertyPattern = new RegExp( + `^\\s*(?:${field}|["']${field}["'])\\s*:\\s*`, + "mi", + ); + assert( + !propertyPattern.test(content), + `${file}: ${field} must not be emitted as a portable model field`, + ); + } + assert( + !/^\s*(?:["']?[\w]*(?:cancel|abort|signal)[\w]*["']?)\s*:\s*/imu.test( + content, + ), + `${file}: runtime cancellation must not be emitted as a portable model field`, + ); + assert( + !/(?:cancel|abort|signal)[^\\/]*\.ya?ml$/iu.test(file), + `${file}: runtime cancellation types must not be portable models`, + ); + } + + for (const protocol of [ + "EnginePermissionPort", + "EngineToolPort", + "EngineDurabilityPort", + "EnginePostCommitPort", + "Executor", + ]) { + const file = join(schemaRoot, `${protocol}.yaml`); + const content = readFileSync(file, "utf8"); + assert( + /^properties:\s*\{\}\s*$/mu.test(content), + `${file}: protocol schemas must not expose properties`, + ); + assert( + !/^required:/mu.test(content), + `${file}: protocol schemas must not expose required wire fields`, + ); + } +} + +function verifyNativeSignatures() { + const root = { + csharp: join( + repoRoot, + "runtime", + "csharp", + "Prompty.Core", + "Model", + "pipeline", + ), + go: join(repoRoot, "runtime", "go", "prompty", "model"), + python: join( + repoRoot, + "runtime", + "python", + "prompty", + "prompty", + "model", + "pipeline", + ), + rust: join( + repoRoot, + "runtime", + "rust", + "prompty", + "src", + "model", + "pipeline", + ), + typescript: join( + repoRoot, + "runtime", + "typescript", + "packages", + "core", + "src", + "model", + "pipeline", + ), + }; + + verifyCSharpSignatures(root.csharp); + verifyGoSignatures(root.go); + verifyTypeScriptSignatures(root.typescript); + verifyRustSignatures(root.rust); + verifyPythonSignatures(root.python); + + const durabilityFiles = [ + join(root.csharp, "EngineDurabilityPort.cs"), + join(root.go, "engine_durability_port.go"), + join(root.python, "_EngineDurabilityPort.py"), + join(root.rust, "engine_durability_port.rs"), + join(root.typescript, "engine-durability-port.ts"), + ]; + for (const file of durabilityFiles) { + const content = readFileSync(file, "utf8"); + for (const forbidden of [ + "CancellationToken", + "context.Context", + "AbortSignal", + ]) { + assert( + !content.includes(forbidden), + `${file}: durability protocol must remain non-cancellable`, + ); + } + } +} + +function verifyCSharpSignatures(root) { + expectMatches( + join(root, "EnginePermissionPort.cs"), + /^\s*Task\s+AuthorizeAsync\(\s*ModelToolRequest request,\s*CancellationToken cancellationToken = default\s*\);/mu, + ); + expectMatches( + join(root, "EngineToolPort.cs"), + /^\s*Task\s+ExecuteAsync\(\s*ModelToolRequest request,\s*CancellationToken cancellationToken = default\s*\);/mu, + ); + expectAllMatches(join(root, "EngineDurabilityPort.cs"), [ + /^\s*Task\s+AppendAsync\(\s*EngineEvent @?event\s*\);/mu, + /^\s*Task\s+AppendWithCheckpointAsync\(\s*List events,\s*EngineCheckpoint checkpoint\s*\);/mu, + ]); + expectMatches( + join(root, "EnginePostCommitPort.cs"), + /^\s*Task\s+AfterCommitAsync\(\s*string effectId,\s*TurnCommit commit,\s*CancellationToken cancellationToken = default\s*\);/mu, + ); + expectAllMatches(join(root, "Executor.cs"), [ + /^\s*Task\s+ExecuteAsync\(\s*Prompty agent,\s*List messages,\s*CancellationToken cancellationToken = default\s*\);/mu, + /^\s*Task\s+ExecuteStreamAsync\(\s*Prompty agent,\s*List messages,\s*CancellationToken cancellationToken = default\s*\)/mu, + /^\s*List\s+FormatToolMessages\(\s*object rawResponse,\s*List toolCalls,\s*List toolResults,\s*string\? textContent\s*\);/mu, + ]); + assertDeclarationNames( + join(root, "EnginePermissionPort.cs"), + /^\s*[A-Za-z_][\w<>,? .]*\s+([A-Z]\w*)\(/gmu, + ["AuthorizeAsync"], + ); + assertDeclarationNames( + join(root, "EngineToolPort.cs"), + /^\s*[A-Za-z_][\w<>,? .]*\s+([A-Z]\w*)\(/gmu, + ["ExecuteAsync"], + ); + assertDeclarationNames( + join(root, "EngineDurabilityPort.cs"), + /^\s*[A-Za-z_][\w<>,? .]*\s+([A-Z]\w*)\(/gmu, + ["AppendAsync", "AppendWithCheckpointAsync"], + ); + assertDeclarationNames( + join(root, "EnginePostCommitPort.cs"), + /^\s*[A-Za-z_][\w<>,? .]*\s+([A-Z]\w*)\(/gmu, + ["AfterCommitAsync"], + ); + assertDeclarationNames( + join(root, "Executor.cs"), + /^\s*[A-Za-z_][\w<>,? .]*\s+([A-Z]\w*)\(/gmu, + ["ExecuteAsync", "ExecuteStreamAsync", "FormatToolMessages"], + ); +} + +function verifyGoSignatures(root) { + expectMatches( + join(root, "engine_permission_port.go"), + /^\s*Authorize\(ctx context\.Context,\s*request ModelToolRequest\)\s*\(EnginePermissionDecision, error\)/mu, + ); + expectMatches( + join(root, "engine_tool_port.go"), + /^\s*Execute\(ctx context\.Context,\s*request ModelToolRequest\)\s*\(ModelToolResult, error\)/mu, + ); + expectAllMatches(join(root, "engine_durability_port.go"), [ + /^\s*Append\(event EngineEvent\)\s*error/mu, + /^\s*AppendWithCheckpoint\(events \[\]EngineEvent,\s*checkpoint EngineCheckpoint\)\s*error/mu, + ]); + expectMatches( + join(root, "engine_post_commit_port.go"), + /^\s*AfterCommit\(ctx context\.Context,\s*effectId string,\s*commit TurnCommit\)\s*error/mu, + ); + expectAllMatches(join(root, "executor.go"), [ + /^\s*Execute\(ctx context\.Context,\s*agent Prompty,\s*messages \[\]Message\)\s*\(interface\{\}, error\)/mu, + /^\s*ExecuteStream\(ctx context\.Context,\s*agent Prompty,\s*messages \[\]Message\)\s*\(interface\{\}, error\)/mu, + /^\s*FormatToolMessages\(rawResponse interface\{\},\s*toolCalls \[\]ToolCall,\s*toolResults \[\]string,\s*textContent \*string\)\s*\(\[\]Message, error\)/mu, + ]); + for (const [file, expected] of [ + ["engine_permission_port.go", ["Authorize"]], + ["engine_tool_port.go", ["Execute"]], + ["engine_durability_port.go", ["Append", "AppendWithCheckpoint"]], + ["engine_post_commit_port.go", ["AfterCommit"]], + ["executor.go", ["Execute", "ExecuteStream", "FormatToolMessages"]], + ]) { + assertDeclarationNames(join(root, file), /^\s*([A-Z]\w*)\(/gmu, expected); + } +} + +function verifyTypeScriptSignatures(root) { + expectMatches( + join(root, "engine-permission-port.ts"), + /^\s{2}authorize\(\s*request: ModelToolRequest,\s*signal\?: AbortSignal,?\s*\): Promise;/mu, + ); + expectMatches( + join(root, "engine-tool-port.ts"), + /^\s{2}execute\(\s*request: ModelToolRequest,\s*signal\?: AbortSignal,?\s*\): Promise;/mu, + ); + expectAllMatches(join(root, "engine-durability-port.ts"), [ + /^\s{2}append\(\s*event: EngineEvent,?\s*\): Promise;/mu, + /^\s{2}appendWithCheckpoint\(\s*events: EngineEvent\[\],\s*checkpoint: EngineCheckpoint,?\s*\): Promise;/mu, + ]); + expectMatches( + join(root, "engine-post-commit-port.ts"), + /^\s{2}afterCommit\(\s*effectId: string,\s*commit: TurnCommit,\s*signal\?: AbortSignal,?\s*\): Promise;/mu, + ); + expectAllMatches(join(root, "executor.ts"), [ + /^\s{2}execute\(\s*agent: Prompty,\s*messages: Message\[\],\s*signal\?: AbortSignal,?\s*\): Promise;/mu, + /^\s{2}executeStream\?\(\s*agent: Prompty,\s*messages: Message\[\],\s*signal\?: AbortSignal,?\s*\): Promise;/mu, + /^\s{2}formatToolMessages\(\s*rawResponse: unknown,\s*toolCalls: ToolCall\[\],\s*toolResults: string\[\],\s*textContent: string \| null,?\s*\): Message\[\];/mu, + ]); + for (const [file, expected] of [ + ["engine-permission-port.ts", ["authorize"]], + ["engine-tool-port.ts", ["execute"]], + ["engine-durability-port.ts", ["append", "appendWithCheckpoint"]], + ["engine-post-commit-port.ts", ["afterCommit"]], + ["executor.ts", ["execute", "executeStream", "formatToolMessages"]], + ]) { + assertDeclarationNames( + join(root, file), + /^\s{2}([a-z]\w*)\??\(/gmu, + expected, + ); + } +} + +function verifyRustSignatures(root) { + expectMatches( + join(root, "engine_permission_port.rs"), + /^\s{4}async fn authorize\(\s*&self,\s*request: &ModelToolRequest,\s*cancellation: &CancellationToken,?\s*\)\s*-> Result>;/mu, + ); + expectMatches( + join(root, "engine_tool_port.rs"), + /^\s{4}async fn execute\(\s*&self,\s*request: &ModelToolRequest,\s*cancellation: &CancellationToken,?\s*\)\s*-> Result>;/mu, + ); + expectAllMatches(join(root, "engine_durability_port.rs"), [ + /^\s{4}async fn append\(\s*&self,\s*event: &EngineEvent,?\s*\)\s*-> Result<\(\),\s*Box>;/mu, + /^\s{4}async fn append_with_checkpoint\(\s*&self,\s*events: &Vec,\s*checkpoint: &EngineCheckpoint,?\s*\)\s*-> Result<\(\),\s*Box>;/mu, + ]); + expectMatches( + join(root, "engine_post_commit_port.rs"), + /^\s{4}async fn after_commit\(\s*&self,\s*effect_id: &String,\s*commit: &TurnCommit,\s*cancellation: &CancellationToken,?\s*\)\s*-> Result<\(\),\s*Box>;/mu, + ); + expectAllMatches(join(root, "executor.rs"), [ + /^\s{4}async fn execute\(\s*&self,\s*agent: &Prompty,\s*messages: &Vec,\s*cancellation: &CancellationToken,?\s*\)\s*-> Result>;/mu, + /^\s{4}async fn execute_stream\(\s*&self,\s*agent: &Prompty,\s*messages: &Vec,\s*cancellation: &CancellationToken,?\s*\)\s*-> Result>/mu, + /^\s{4}fn format_tool_messages\(\s*&self,\s*raw_response: &serde_json::Value,\s*tool_calls: &Vec,\s*tool_results: &Vec,\s*text_content: &Option,?\s*\)\s*-> Vec;/mu, + ]); + for (const [file, expected] of [ + ["engine_permission_port.rs", ["authorize"]], + ["engine_tool_port.rs", ["execute"]], + ["engine_durability_port.rs", ["append", "append_with_checkpoint"]], + ["engine_post_commit_port.rs", ["after_commit"]], + ["executor.rs", ["execute", "execute_stream", "format_tool_messages"]], + ]) { + assertDeclarationNames( + join(root, file), + /^\s{4}(?:async\s+)?fn\s+([a-z]\w*)\(/gmu, + expected, + ); + } +} + +function verifyPythonSignatures(root) { + expectAllMatches(join(root, "_EnginePermissionPort.py"), [ + /^\s{4}def authorize\(\s*self,\s*request: ModelToolRequest,\s*cancellation: CancellationToken \| None = None\s*\)\s*-> EnginePermissionDecision:/mu, + /^\s{4}async def authorize_async\(\s*self,\s*request: ModelToolRequest,\s*cancellation: CancellationToken \| None = None\s*\)\s*-> EnginePermissionDecision:/mu, + ]); + expectAllMatches(join(root, "_EngineToolPort.py"), [ + /^\s{4}def execute\(\s*self,\s*request: ModelToolRequest,\s*cancellation: CancellationToken \| None = None\s*\)\s*-> ModelToolResult:/mu, + /^\s{4}async def execute_async\(\s*self,\s*request: ModelToolRequest,\s*cancellation: CancellationToken \| None = None\s*\)\s*-> ModelToolResult:/mu, + ]); + expectAllMatches(join(root, "_EngineDurabilityPort.py"), [ + /^\s{4}def append\(\s*self,\s*event: EngineEvent\s*\)\s*-> None:/mu, + /^\s{4}async def append_async\(\s*self,\s*event: EngineEvent\s*\)\s*-> None:/mu, + /^\s{4}def append_with_checkpoint\(\s*self,\s*events: list\[EngineEvent\],\s*checkpoint: EngineCheckpoint\s*\)\s*-> None:/mu, + /^\s{4}async def append_with_checkpoint_async\(\s*self,\s*events: list\[EngineEvent\],\s*checkpoint: EngineCheckpoint\s*\)\s*-> None:/mu, + ]); + expectAllMatches(join(root, "_EnginePostCommitPort.py"), [ + /^\s{4}def after_commit\(\s*self,\s*effect_id: str,\s*commit: TurnCommit,\s*cancellation: CancellationToken \| None = None\s*\)\s*-> None:/mu, + /^\s{4}async def after_commit_async\(\s*self,\s*effect_id: str,\s*commit: TurnCommit,\s*cancellation: CancellationToken \| None = None\s*\)\s*-> None:/mu, + ]); + expectAllMatches(join(root, "_Executor.py"), [ + /^\s{4}def execute\(\s*self,\s*agent: Prompty,\s*messages: list\[Message\],\s*cancellation: CancellationToken \| None = None\s*\)\s*-> Any:/mu, + /^\s{4}async def execute_async\(\s*self,\s*agent: Prompty,\s*messages: list\[Message\],\s*cancellation: CancellationToken \| None = None\s*\)\s*-> Any:/mu, + /^\s{4}def execute_stream\(\s*self,\s*agent: Prompty,\s*messages: list\[Message\],\s*cancellation: CancellationToken \| None = None\s*\)\s*-> Any:/mu, + /^\s{4}async def execute_stream_async\(\s*self,\s*agent: Prompty,\s*messages: list\[Message\],\s*cancellation: CancellationToken \| None = None\s*\)\s*-> Any:/mu, + /^\s{4}def format_tool_messages\(\s*self,\s*raw_response: Any,\s*tool_calls: list\[ToolCall\],\s*tool_results: list\[str\],\s*text_content: str \| None\s*\)\s*-> list\[Message\]:/mu, + ]); + for (const [file, expected] of [ + ["_EnginePermissionPort.py", ["authorize", "authorize_async"]], + ["_EngineToolPort.py", ["execute", "execute_async"]], + [ + "_EngineDurabilityPort.py", + [ + "append", + "append_async", + "append_with_checkpoint", + "append_with_checkpoint_async", + ], + ], + ["_EnginePostCommitPort.py", ["after_commit", "after_commit_async"]], + [ + "_Executor.py", + [ + "execute", + "execute_async", + "execute_stream", + "execute_stream_async", + "format_tool_messages", + ], + ], + ]) { + assertDeclarationNames( + join(root, file), + /^\s{4}(?:async\s+)?def\s+([a-z]\w*)\(/gmu, + expected, + ); + } +} + +function verifyMarkdownSemantics() { + const root = join(repoRoot, "web", "src", "content", "docs", "reference"); + expectMarkdownMethod( + join(root, "EnginePermissionPort.md"), + "authorize", + "authorize(request: ModelToolRequest) -> EnginePermissionDecision", + ["async-capable", "runtime-cancellable"], + ["atomic", "non-fatal", "sync"], + ); + expectMarkdownMethod( + join(root, "EngineToolPort.md"), + "execute", + "execute(request: ModelToolRequest) -> ModelToolResult", + ["async-capable", "runtime-cancellable"], + ["atomic", "non-fatal", "sync"], + ); + expectMarkdownMethod( + join(root, "EngineDurabilityPort.md"), + "append", + "append(event: EngineEvent) -> void", + ["async-capable"], + ["runtime-cancellable", "atomic", "non-fatal", "sync"], + ); + expectMarkdownMethod( + join(root, "EngineDurabilityPort.md"), + "appendWithCheckpoint", + "appendWithCheckpoint(events: EngineEvent[], checkpoint: EngineCheckpoint) -> void", + ["async-capable", "atomic"], + ["runtime-cancellable", "non-fatal", "sync"], + ); + expectMarkdownMethod( + join(root, "EnginePostCommitPort.md"), + "afterCommit", + "afterCommit(effectId: string, commit: TurnCommit) -> void", + ["async-capable", "runtime-cancellable", "non-fatal"], + ["atomic", "sync"], + ); + expectMarkdownMethod( + join(root, "Executor.md"), + "execute", + "execute(agent: Prompty, messages: Message[]) -> unknown", + ["async-capable", "runtime-cancellable"], + ["atomic", "non-fatal", "sync"], + ); + expectMarkdownMethod( + join(root, "Executor.md"), + "executeStream", + "executeStream(agent: Prompty, messages: Message[]) -> unknown", + ["async-capable", "runtime-cancellable"], + ["atomic", "non-fatal", "sync"], + ); + expectMarkdownMethod( + join(root, "Executor.md"), + "formatToolMessages", + "formatToolMessages(rawResponse: unknown, toolCalls: ToolCall[], toolResults: string[], textContent: string?) -> Message[]", + ["sync"], + ["async-capable", "runtime-cancellable", "atomic", "non-fatal"], + ); +} + +function expectMarkdownMethod( + path, + methodName, + signature, + requiredEffects, + forbiddenEffects, +) { + const content = readFileSync(path, "utf8"); + const row = content + .split(/\r?\n/u) + .find((line) => line.startsWith(`| \`${methodName}\` |`)); + assert(row, `${path}: missing ${methodName} helper-method row`); + + const columns = row.split("|"); + assertEqual( + columns[2].trim(), + `\`${signature}\``, + `${path}: ${methodName} signature`, + ); + const runtimeShape = columns[3].trim().toLowerCase(); + for (const effect of requiredEffects) { + assert( + hasRuntimeEffect(runtimeShape, effect), + `${path}: ${methodName} runtime shape must include ${effect}`, + ); + } + for (const effect of forbiddenEffects) { + assert( + !hasRuntimeEffect(runtimeShape, effect), + `${path}: ${methodName} runtime shape must not include ${effect}`, + ); + } +} + +function hasRuntimeEffect(runtimeShape, effect) { + const escaped = effect.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const normalized = runtimeShape.replace(/[_()]/gu, " "); + return new RegExp(`(?:^|[,\\s])${escaped}(?=$|[,\\s])`, "u").test(normalized); +} + +function expectMatches(path, pattern) { + const content = readFileSync(path, "utf8"); + assert( + pattern.test(content), + `${path}: generated signature did not match ${pattern}`, + ); +} + +function expectAllMatches(path, patterns) { + for (const pattern of patterns) { + expectMatches(path, pattern); + } +} + +function assertDeclarationNames(path, pattern, expected) { + const content = readFileSync(path, "utf8"); + const actual = [...content.matchAll(pattern)].map((match) => match[1]).sort(); + assertEqual( + JSON.stringify(actual), + JSON.stringify([...expected].sort()), + `${path}: native method declarations`, + ); +} + +function collectFiles(root) { + const files = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) { + files.push(...collectFiles(path)); + } else if (entry.isFile()) { + files.push(path); + } + } + return files; +} + +function assertSubset(actual, expected, label) { + for (const [key, expectedValue] of Object.entries(expected)) { + const actualValue = actual[key]; + if ( + expectedValue !== null && + typeof expectedValue === "object" && + !Array.isArray(expectedValue) + ) { + assert( + actualValue !== null && typeof actualValue === "object", + `${label}.${key}: expected an object`, + ); + assertSubset(actualValue, expectedValue, `${label}.${key}`); + } else { + assertEqual(actualValue, expectedValue, `${label}.${key}`); + } + } +} + +function assertExactKeys(actual, expected, label) { + const actualKeys = Object.keys(actual).sort(); + const expectedKeys = Object.keys(expected).sort(); + assertEqual( + JSON.stringify(actualKeys), + JSON.stringify(expectedKeys), + `${label} keys`, + ); +} + +function assertUniqueNames(items, label) { + const names = items.map((item) => item.name); + assertEqual( + new Set(names).size, + names.length, + `${label}: duplicate names are not allowed`, + ); +} + +function assertEqual(actual, expected, label) { + assert( + actual === expected, + `${label}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`, + ); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/schema/tsp-output/.typra-generated/export-surfaces.json b/schema/tsp-output/.typra-generated/export-surfaces.json index 45c400eb4..dd08bc569 100644 --- a/schema/tsp-output/.typra-generated/export-surfaces.json +++ b/schema/tsp-output/.typra-generated/export-surfaces.json @@ -17,8 +17,8 @@ }, { "name": "@typra/emitter", - "version": "0.4.2", - "supportedRange": "0.4.2", + "version": "0.4.30", + "supportedRange": "0.4.30", "supported": true } ] @@ -66,8 +66,12 @@ "DeviceAuthorization", "DoneEventPayload", "EngineCheckpoint", + "EngineDurabilityPort", "EngineEvent", "EnginePermissionDecision", + "EnginePermissionPort", + "EnginePostCommitPort", + "EngineToolPort", "ErrorChunk", "ErrorEventPayload", "EventJournalWriter", @@ -858,6 +862,13 @@ "source": "pipeline/EngineCheckpoint.cs", "protocol": false }, + { + "name": "EngineDurabilityPort", + "kind": "type", + "group": "pipeline", + "source": "pipeline/EngineDurabilityPort.cs", + "protocol": true + }, { "name": "EngineEvent", "kind": "value", @@ -872,6 +883,27 @@ "source": "pipeline/EnginePermissionDecision.cs", "protocol": false }, + { + "name": "EnginePermissionPort", + "kind": "type", + "group": "pipeline", + "source": "pipeline/EnginePermissionPort.cs", + "protocol": true + }, + { + "name": "EnginePostCommitPort", + "kind": "type", + "group": "pipeline", + "source": "pipeline/EnginePostCommitPort.cs", + "protocol": true + }, + { + "name": "EngineToolPort", + "kind": "type", + "group": "pipeline", + "source": "pipeline/EngineToolPort.cs", + "protocol": true + }, { "name": "EventJournalWriter", "kind": "type", @@ -1512,8 +1544,12 @@ "ContextRequest", "DelegatedStateReference", "EngineCheckpoint", + "EngineDurabilityPort", "EngineEvent", "EnginePermissionDecision", + "EnginePermissionPort", + "EnginePostCommitPort", + "EngineToolPort", "EventJournalWriter", "EventSink", "Executor", @@ -1555,8 +1591,12 @@ "ContextRequest.cs", "DelegatedStateReference.cs", "EngineCheckpoint.cs", + "EngineDurabilityPort.cs", "EngineEvent.cs", "EnginePermissionDecision.cs", + "EnginePermissionPort.cs", + "EnginePostCommitPort.cs", + "EngineToolPort.cs", "EventJournalWriter.cs", "EventSink.cs", "Executor.cs", @@ -1691,7 +1731,10 @@ "connection": "unknown" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -1708,7 +1751,10 @@ "sessionId": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "load", @@ -1718,7 +1764,10 @@ "sessionId": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "save", @@ -1727,7 +1776,104 @@ "checkpoint": "Checkpoint" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false + } + ] + }, + { + "name": "EngineDurabilityPort", + "group": "pipeline", + "symbol": "EngineDurabilityPort", + "source": "pipeline/EngineDurabilityPort.cs", + "methods": [ + { + "name": "append", + "returns": "void", + "params": { + "event": "EngineEvent" + }, + "optional": false, + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false + }, + { + "name": "appendWithCheckpoint", + "returns": "void", + "params": { + "checkpoint": "EngineCheckpoint", + "events": "EngineEvent[]" + }, + "optional": false, + "sync": false, + "runtimeCancellable": false, + "atomic": true, + "nonFatal": false + } + ] + }, + { + "name": "EnginePermissionPort", + "group": "pipeline", + "symbol": "EnginePermissionPort", + "source": "pipeline/EnginePermissionPort.cs", + "methods": [ + { + "name": "authorize", + "returns": "EnginePermissionDecision", + "params": { + "request": "ModelToolRequest" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false + } + ] + }, + { + "name": "EnginePostCommitPort", + "group": "pipeline", + "symbol": "EnginePostCommitPort", + "source": "pipeline/EnginePostCommitPort.cs", + "methods": [ + { + "name": "afterCommit", + "returns": "void", + "params": { + "commit": "TurnCommit", + "effectId": "string" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": true + } + ] + }, + { + "name": "EngineToolPort", + "group": "pipeline", + "symbol": "EngineToolPort", + "source": "pipeline/EngineToolPort.cs", + "methods": [ + { + "name": "execute", + "returns": "ModelToolResult", + "params": { + "request": "ModelToolRequest" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false } ] }, @@ -1744,7 +1890,10 @@ "sessionEvent": "SessionEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "appendTurn", @@ -1753,7 +1902,10 @@ "turnEvent": "TurnEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "close", @@ -1762,7 +1914,10 @@ "summary": "SessionSummary?" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -1779,7 +1934,10 @@ "sessionEvent": "SessionEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "emitTurn", @@ -1788,7 +1946,10 @@ "turnEvent": "TurnEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -1806,7 +1967,10 @@ "messages": "Message[]" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false }, { "name": "executeStream", @@ -1816,7 +1980,10 @@ "messages": "Message[]" }, "optional": true, - "sync": false + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false }, { "name": "formatToolMessages", @@ -1828,7 +1995,10 @@ "toolResults": "string[]" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -1845,7 +2015,10 @@ "request": "HostToolRequest" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -1864,7 +2037,10 @@ "rendered": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "preRender", @@ -1873,7 +2049,10 @@ "template": "string" }, "optional": true, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -1890,7 +2069,10 @@ "request": "PermissionRequest" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -1908,7 +2090,10 @@ "response": "unknown" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "processStream", @@ -1917,7 +2102,10 @@ "stream": "unknown" }, "optional": true, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -1936,7 +2124,10 @@ "template": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] } @@ -2019,8 +2210,12 @@ "pipeline/ContextRequest.cs", "pipeline/DelegatedStateReference.cs", "pipeline/EngineCheckpoint.cs", + "pipeline/EngineDurabilityPort.cs", "pipeline/EngineEvent.cs", "pipeline/EnginePermissionDecision.cs", + "pipeline/EnginePermissionPort.cs", + "pipeline/EnginePostCommitPort.cs", + "pipeline/EngineToolPort.cs", "pipeline/EventJournalWriter.cs", "pipeline/EventSink.cs", "pipeline/Executor.cs", @@ -2115,8 +2310,12 @@ "DeviceAuthorization", "DoneEventPayload", "EngineCheckpoint", + "EngineDurabilityPort", "EngineEvent", "EnginePermissionDecision", + "EnginePermissionPort", + "EnginePostCommitPort", + "EngineToolPort", "ErrorChunk", "ErrorEventPayload", "EventJournalWriter", @@ -2907,6 +3106,13 @@ "source": "engine_checkpoint.go", "protocol": false }, + { + "name": "EngineDurabilityPort", + "kind": "type", + "group": "pipeline", + "source": "engine_durability_port.go", + "protocol": true + }, { "name": "EngineEvent", "kind": "value", @@ -2921,6 +3127,27 @@ "source": "engine_permission_decision.go", "protocol": false }, + { + "name": "EnginePermissionPort", + "kind": "type", + "group": "pipeline", + "source": "engine_permission_port.go", + "protocol": true + }, + { + "name": "EnginePostCommitPort", + "kind": "type", + "group": "pipeline", + "source": "engine_post_commit_port.go", + "protocol": true + }, + { + "name": "EngineToolPort", + "kind": "type", + "group": "pipeline", + "source": "engine_tool_port.go", + "protocol": true + }, { "name": "EventJournalWriter", "kind": "type", @@ -3561,8 +3788,12 @@ "ContextRequest", "DelegatedStateReference", "EngineCheckpoint", + "EngineDurabilityPort", "EngineEvent", "EnginePermissionDecision", + "EnginePermissionPort", + "EnginePostCommitPort", + "EngineToolPort", "EventJournalWriter", "EventSink", "Executor", @@ -3604,8 +3835,12 @@ "context_request", "delegated_state_reference", "engine_checkpoint", + "engine_durability_port", "engine_event", "engine_permission_decision", + "engine_permission_port", + "engine_post_commit_port", + "engine_tool_port", "event_journal_writer", "event_sink", "executor", @@ -3740,7 +3975,10 @@ "connection": "unknown" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -3757,7 +3995,10 @@ "sessionId": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "load", @@ -3767,7 +4008,10 @@ "sessionId": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "save", @@ -3776,7 +4020,104 @@ "checkpoint": "Checkpoint" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false + } + ] + }, + { + "name": "EngineDurabilityPort", + "group": "pipeline", + "symbol": "EngineDurabilityPort", + "source": "engine_durability_port.go", + "methods": [ + { + "name": "append", + "returns": "void", + "params": { + "event": "EngineEvent" + }, + "optional": false, + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false + }, + { + "name": "appendWithCheckpoint", + "returns": "void", + "params": { + "checkpoint": "EngineCheckpoint", + "events": "EngineEvent[]" + }, + "optional": false, + "sync": false, + "runtimeCancellable": false, + "atomic": true, + "nonFatal": false + } + ] + }, + { + "name": "EnginePermissionPort", + "group": "pipeline", + "symbol": "EnginePermissionPort", + "source": "engine_permission_port.go", + "methods": [ + { + "name": "authorize", + "returns": "EnginePermissionDecision", + "params": { + "request": "ModelToolRequest" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false + } + ] + }, + { + "name": "EnginePostCommitPort", + "group": "pipeline", + "symbol": "EnginePostCommitPort", + "source": "engine_post_commit_port.go", + "methods": [ + { + "name": "afterCommit", + "returns": "void", + "params": { + "commit": "TurnCommit", + "effectId": "string" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": true + } + ] + }, + { + "name": "EngineToolPort", + "group": "pipeline", + "symbol": "EngineToolPort", + "source": "engine_tool_port.go", + "methods": [ + { + "name": "execute", + "returns": "ModelToolResult", + "params": { + "request": "ModelToolRequest" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false } ] }, @@ -3793,7 +4134,10 @@ "sessionEvent": "SessionEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "appendTurn", @@ -3802,7 +4146,10 @@ "turnEvent": "TurnEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "close", @@ -3811,7 +4158,10 @@ "summary": "SessionSummary?" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -3828,7 +4178,10 @@ "sessionEvent": "SessionEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "emitTurn", @@ -3837,7 +4190,10 @@ "turnEvent": "TurnEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -3855,7 +4211,10 @@ "messages": "Message[]" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false }, { "name": "executeStream", @@ -3865,7 +4224,10 @@ "messages": "Message[]" }, "optional": true, - "sync": false + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false }, { "name": "formatToolMessages", @@ -3877,7 +4239,10 @@ "toolResults": "string[]" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -3894,7 +4259,10 @@ "request": "HostToolRequest" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -3913,7 +4281,10 @@ "rendered": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "preRender", @@ -3922,7 +4293,10 @@ "template": "string" }, "optional": true, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -3939,7 +4313,10 @@ "request": "PermissionRequest" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -3957,7 +4334,10 @@ "response": "unknown" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "processStream", @@ -3966,7 +4346,10 @@ "stream": "unknown" }, "optional": true, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -3985,7 +4368,10 @@ "template": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] } @@ -4018,8 +4404,12 @@ "device_authorization.go", "done_event_payload.go", "engine_checkpoint.go", + "engine_durability_port.go", "engine_event.go", "engine_permission_decision.go", + "engine_permission_port.go", + "engine_post_commit_port.go", + "engine_tool_port.go", "error_event_payload.go", "event_journal_writer.go", "event_sink.go", @@ -4163,8 +4553,12 @@ "DeviceAuthorization", "DoneEventPayload", "EngineCheckpoint", + "EngineDurabilityPort", "EngineEvent", "EnginePermissionDecision", + "EnginePermissionPort", + "EnginePostCommitPort", + "EngineToolPort", "ErrorChunk", "ErrorEventPayload", "EventJournalWriter", @@ -4955,6 +5349,13 @@ "source": "EngineCheckpoint", "protocol": false }, + { + "name": "EngineDurabilityPort", + "kind": "type", + "group": "pipeline", + "source": "EngineDurabilityPort", + "protocol": true + }, { "name": "EngineEvent", "kind": "value", @@ -4969,6 +5370,27 @@ "source": "EnginePermissionDecision", "protocol": false }, + { + "name": "EnginePermissionPort", + "kind": "type", + "group": "pipeline", + "source": "EnginePermissionPort", + "protocol": true + }, + { + "name": "EnginePostCommitPort", + "kind": "type", + "group": "pipeline", + "source": "EnginePostCommitPort", + "protocol": true + }, + { + "name": "EngineToolPort", + "kind": "type", + "group": "pipeline", + "source": "EngineToolPort", + "protocol": true + }, { "name": "EventJournalWriter", "kind": "type", @@ -5609,8 +6031,12 @@ "ContextRequest", "DelegatedStateReference", "EngineCheckpoint", + "EngineDurabilityPort", "EngineEvent", "EnginePermissionDecision", + "EnginePermissionPort", + "EnginePostCommitPort", + "EngineToolPort", "EventJournalWriter", "EventSink", "Executor", @@ -5652,8 +6078,12 @@ "context_request", "delegated_state_reference", "engine_checkpoint", + "engine_durability_port", "engine_event", "engine_permission_decision", + "engine_permission_port", + "engine_post_commit_port", + "engine_tool_port", "event_journal_writer", "event_sink", "executor", @@ -5788,7 +6218,10 @@ "connection": "unknown" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -5805,7 +6238,10 @@ "sessionId": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "load", @@ -5815,7 +6251,10 @@ "sessionId": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "save", @@ -5824,7 +6263,104 @@ "checkpoint": "Checkpoint" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false + } + ] + }, + { + "name": "EngineDurabilityPort", + "group": "pipeline", + "symbol": "EngineDurabilityPort", + "source": "EngineDurabilityPort", + "methods": [ + { + "name": "append", + "returns": "void", + "params": { + "event": "EngineEvent" + }, + "optional": false, + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false + }, + { + "name": "appendWithCheckpoint", + "returns": "void", + "params": { + "checkpoint": "EngineCheckpoint", + "events": "EngineEvent[]" + }, + "optional": false, + "sync": false, + "runtimeCancellable": false, + "atomic": true, + "nonFatal": false + } + ] + }, + { + "name": "EnginePermissionPort", + "group": "pipeline", + "symbol": "EnginePermissionPort", + "source": "EnginePermissionPort", + "methods": [ + { + "name": "authorize", + "returns": "EnginePermissionDecision", + "params": { + "request": "ModelToolRequest" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false + } + ] + }, + { + "name": "EnginePostCommitPort", + "group": "pipeline", + "symbol": "EnginePostCommitPort", + "source": "EnginePostCommitPort", + "methods": [ + { + "name": "afterCommit", + "returns": "void", + "params": { + "commit": "TurnCommit", + "effectId": "string" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": true + } + ] + }, + { + "name": "EngineToolPort", + "group": "pipeline", + "symbol": "EngineToolPort", + "source": "EngineToolPort", + "methods": [ + { + "name": "execute", + "returns": "ModelToolResult", + "params": { + "request": "ModelToolRequest" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false } ] }, @@ -5841,7 +6377,10 @@ "sessionEvent": "SessionEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "appendTurn", @@ -5850,7 +6389,10 @@ "turnEvent": "TurnEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "close", @@ -5859,7 +6401,10 @@ "summary": "SessionSummary?" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -5876,7 +6421,10 @@ "sessionEvent": "SessionEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "emitTurn", @@ -5885,7 +6433,10 @@ "turnEvent": "TurnEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -5903,7 +6454,10 @@ "messages": "Message[]" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false }, { "name": "executeStream", @@ -5913,7 +6467,10 @@ "messages": "Message[]" }, "optional": true, - "sync": false + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false }, { "name": "formatToolMessages", @@ -5925,7 +6482,10 @@ "toolResults": "string[]" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -5942,7 +6502,10 @@ "request": "HostToolRequest" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -5961,7 +6524,10 @@ "rendered": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "preRender", @@ -5970,7 +6536,10 @@ "template": "string" }, "optional": true, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -5987,7 +6556,10 @@ "request": "PermissionRequest" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -6005,7 +6577,10 @@ "response": "unknown" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "processStream", @@ -6014,7 +6589,10 @@ "stream": "unknown" }, "optional": true, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -6033,7 +6611,10 @@ "template": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] } @@ -6066,8 +6647,12 @@ "DeviceAuthorization", "DoneEventPayload", "EngineCheckpoint", + "EngineDurabilityPort", "EngineEvent", "EnginePermissionDecision", + "EnginePermissionPort", + "EnginePostCommitPort", + "EngineToolPort", "ErrorEventPayload", "EventJournalWriter", "EventSink", @@ -6212,8 +6797,12 @@ "DeviceAuthorization", "DoneEventPayload", "EngineCheckpoint", + "EngineDurabilityPort", "EngineEvent", "EnginePermissionDecision", + "EnginePermissionPort", + "EnginePostCommitPort", + "EngineToolPort", "ErrorChunk", "ErrorEventPayload", "EventJournalWriter", @@ -7004,6 +7593,13 @@ "source": ".pipeline", "protocol": false }, + { + "name": "EngineDurabilityPort", + "kind": "type", + "group": "pipeline", + "source": ".pipeline", + "protocol": true + }, { "name": "EngineEvent", "kind": "value", @@ -7018,6 +7614,27 @@ "source": ".pipeline", "protocol": false }, + { + "name": "EnginePermissionPort", + "kind": "type", + "group": "pipeline", + "source": ".pipeline", + "protocol": true + }, + { + "name": "EnginePostCommitPort", + "kind": "type", + "group": "pipeline", + "source": ".pipeline", + "protocol": true + }, + { + "name": "EngineToolPort", + "kind": "type", + "group": "pipeline", + "source": ".pipeline", + "protocol": true + }, { "name": "EventJournalWriter", "kind": "type", @@ -7658,8 +8275,12 @@ "ContextRequest", "DelegatedStateReference", "EngineCheckpoint", + "EngineDurabilityPort", "EngineEvent", "EnginePermissionDecision", + "EnginePermissionPort", + "EnginePostCommitPort", + "EngineToolPort", "EventJournalWriter", "EventSink", "Executor", @@ -7701,8 +8322,12 @@ "_ContextRequest", "_DelegatedStateReference", "_EngineCheckpoint", + "_EngineDurabilityPort", "_EngineEvent", "_EnginePermissionDecision", + "_EnginePermissionPort", + "_EnginePostCommitPort", + "_EngineToolPort", "_EventJournalWriter", "_EventSink", "_Executor", @@ -7837,7 +8462,10 @@ "connection": "unknown" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -7854,7 +8482,10 @@ "sessionId": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "load", @@ -7864,7 +8495,10 @@ "sessionId": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "save", @@ -7873,7 +8507,104 @@ "checkpoint": "Checkpoint" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false + } + ] + }, + { + "name": "EngineDurabilityPort", + "group": "pipeline", + "symbol": "EngineDurabilityPort", + "source": ".pipeline", + "methods": [ + { + "name": "append", + "returns": "void", + "params": { + "event": "EngineEvent" + }, + "optional": false, + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false + }, + { + "name": "appendWithCheckpoint", + "returns": "void", + "params": { + "checkpoint": "EngineCheckpoint", + "events": "EngineEvent[]" + }, + "optional": false, + "sync": false, + "runtimeCancellable": false, + "atomic": true, + "nonFatal": false + } + ] + }, + { + "name": "EnginePermissionPort", + "group": "pipeline", + "symbol": "EnginePermissionPort", + "source": ".pipeline", + "methods": [ + { + "name": "authorize", + "returns": "EnginePermissionDecision", + "params": { + "request": "ModelToolRequest" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false + } + ] + }, + { + "name": "EnginePostCommitPort", + "group": "pipeline", + "symbol": "EnginePostCommitPort", + "source": ".pipeline", + "methods": [ + { + "name": "afterCommit", + "returns": "void", + "params": { + "commit": "TurnCommit", + "effectId": "string" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": true + } + ] + }, + { + "name": "EngineToolPort", + "group": "pipeline", + "symbol": "EngineToolPort", + "source": ".pipeline", + "methods": [ + { + "name": "execute", + "returns": "ModelToolResult", + "params": { + "request": "ModelToolRequest" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false } ] }, @@ -7890,7 +8621,10 @@ "sessionEvent": "SessionEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "appendTurn", @@ -7899,7 +8633,10 @@ "turnEvent": "TurnEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "close", @@ -7908,7 +8645,10 @@ "summary": "SessionSummary?" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -7925,7 +8665,10 @@ "sessionEvent": "SessionEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "emitTurn", @@ -7934,7 +8677,10 @@ "turnEvent": "TurnEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -7952,7 +8698,10 @@ "messages": "Message[]" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false }, { "name": "executeStream", @@ -7962,7 +8711,10 @@ "messages": "Message[]" }, "optional": true, - "sync": false + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false }, { "name": "formatToolMessages", @@ -7974,7 +8726,10 @@ "toolResults": "string[]" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -7991,7 +8746,10 @@ "request": "HostToolRequest" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -8010,7 +8768,10 @@ "rendered": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "preRender", @@ -8019,7 +8780,10 @@ "template": "string" }, "optional": true, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -8036,7 +8800,10 @@ "request": "PermissionRequest" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -8054,7 +8821,10 @@ "response": "unknown" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "processStream", @@ -8063,7 +8833,10 @@ "stream": "unknown" }, "optional": true, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -8082,7 +8855,10 @@ "template": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] } @@ -8139,8 +8915,12 @@ "DeviceAuthorization", "DoneEventPayload", "EngineCheckpoint", + "EngineDurabilityPort", "EngineEvent", "EnginePermissionDecision", + "EnginePermissionPort", + "EnginePostCommitPort", + "EngineToolPort", "ErrorChunk", "ErrorEventPayload", "EventJournalWriter", @@ -8931,6 +9711,13 @@ "source": "pipeline::engine_checkpoint", "protocol": false }, + { + "name": "EngineDurabilityPort", + "kind": "type", + "group": "pipeline", + "source": "pipeline::engine_durability_port", + "protocol": true + }, { "name": "EngineEvent", "kind": "value", @@ -8945,6 +9732,27 @@ "source": "pipeline::engine_permission_decision", "protocol": false }, + { + "name": "EnginePermissionPort", + "kind": "type", + "group": "pipeline", + "source": "pipeline::engine_permission_port", + "protocol": true + }, + { + "name": "EnginePostCommitPort", + "kind": "type", + "group": "pipeline", + "source": "pipeline::engine_post_commit_port", + "protocol": true + }, + { + "name": "EngineToolPort", + "kind": "type", + "group": "pipeline", + "source": "pipeline::engine_tool_port", + "protocol": true + }, { "name": "EventJournalWriter", "kind": "type", @@ -9585,8 +10393,12 @@ "ContextRequest", "DelegatedStateReference", "EngineCheckpoint", + "EngineDurabilityPort", "EngineEvent", "EnginePermissionDecision", + "EnginePermissionPort", + "EnginePostCommitPort", + "EngineToolPort", "EventJournalWriter", "EventSink", "Executor", @@ -9628,8 +10440,12 @@ "context_request", "delegated_state_reference", "engine_checkpoint", + "engine_durability_port", "engine_event", "engine_permission_decision", + "engine_permission_port", + "engine_post_commit_port", + "engine_tool_port", "event_journal_writer", "event_sink", "executor", @@ -9764,7 +10580,10 @@ "connection": "unknown" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -9781,7 +10600,10 @@ "sessionId": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "load", @@ -9791,7 +10613,10 @@ "sessionId": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "save", @@ -9800,7 +10625,104 @@ "checkpoint": "Checkpoint" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false + } + ] + }, + { + "name": "EngineDurabilityPort", + "group": "pipeline", + "symbol": "EngineDurabilityPort", + "source": "pipeline::engine_durability_port", + "methods": [ + { + "name": "append", + "returns": "void", + "params": { + "event": "EngineEvent" + }, + "optional": false, + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false + }, + { + "name": "appendWithCheckpoint", + "returns": "void", + "params": { + "checkpoint": "EngineCheckpoint", + "events": "EngineEvent[]" + }, + "optional": false, + "sync": false, + "runtimeCancellable": false, + "atomic": true, + "nonFatal": false + } + ] + }, + { + "name": "EnginePermissionPort", + "group": "pipeline", + "symbol": "EnginePermissionPort", + "source": "pipeline::engine_permission_port", + "methods": [ + { + "name": "authorize", + "returns": "EnginePermissionDecision", + "params": { + "request": "ModelToolRequest" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false + } + ] + }, + { + "name": "EnginePostCommitPort", + "group": "pipeline", + "symbol": "EnginePostCommitPort", + "source": "pipeline::engine_post_commit_port", + "methods": [ + { + "name": "afterCommit", + "returns": "void", + "params": { + "commit": "TurnCommit", + "effectId": "string" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": true + } + ] + }, + { + "name": "EngineToolPort", + "group": "pipeline", + "symbol": "EngineToolPort", + "source": "pipeline::engine_tool_port", + "methods": [ + { + "name": "execute", + "returns": "ModelToolResult", + "params": { + "request": "ModelToolRequest" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false } ] }, @@ -9817,7 +10739,10 @@ "sessionEvent": "SessionEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "appendTurn", @@ -9826,7 +10751,10 @@ "turnEvent": "TurnEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "close", @@ -9835,7 +10763,10 @@ "summary": "SessionSummary?" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -9852,7 +10783,10 @@ "sessionEvent": "SessionEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "emitTurn", @@ -9861,7 +10795,10 @@ "turnEvent": "TurnEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -9879,7 +10816,10 @@ "messages": "Message[]" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false }, { "name": "executeStream", @@ -9889,7 +10829,10 @@ "messages": "Message[]" }, "optional": true, - "sync": false + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false }, { "name": "formatToolMessages", @@ -9901,7 +10844,10 @@ "toolResults": "string[]" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -9918,7 +10864,10 @@ "request": "HostToolRequest" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -9937,7 +10886,10 @@ "rendered": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "preRender", @@ -9946,7 +10898,10 @@ "template": "string" }, "optional": true, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -9963,7 +10918,10 @@ "request": "PermissionRequest" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -9981,7 +10939,10 @@ "response": "unknown" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "processStream", @@ -9990,7 +10951,10 @@ "stream": "unknown" }, "optional": true, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -10009,7 +10973,10 @@ "template": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] } @@ -10068,8 +11035,12 @@ "DeviceAuthorization", "DoneEventPayload", "EngineCheckpoint", + "EngineDurabilityPort", "EngineEvent", "EnginePermissionDecision", + "EnginePermissionPort", + "EnginePostCommitPort", + "EngineToolPort", "ErrorChunk", "ErrorEventPayload", "EventJournalWriter", @@ -10860,6 +11831,13 @@ "source": "./pipeline/engine-checkpoint", "protocol": false }, + { + "name": "EngineDurabilityPort", + "kind": "type", + "group": "pipeline", + "source": "./pipeline/engine-durability-port", + "protocol": true + }, { "name": "EngineEvent", "kind": "value", @@ -10874,6 +11852,27 @@ "source": "./pipeline/engine-permission-decision", "protocol": false }, + { + "name": "EnginePermissionPort", + "kind": "type", + "group": "pipeline", + "source": "./pipeline/engine-permission-port", + "protocol": true + }, + { + "name": "EnginePostCommitPort", + "kind": "type", + "group": "pipeline", + "source": "./pipeline/engine-post-commit-port", + "protocol": true + }, + { + "name": "EngineToolPort", + "kind": "type", + "group": "pipeline", + "source": "./pipeline/engine-tool-port", + "protocol": true + }, { "name": "EventJournalWriter", "kind": "type", @@ -11514,8 +12513,12 @@ "ContextRequest", "DelegatedStateReference", "EngineCheckpoint", + "EngineDurabilityPort", "EngineEvent", "EnginePermissionDecision", + "EnginePermissionPort", + "EnginePostCommitPort", + "EngineToolPort", "EventJournalWriter", "EventSink", "Executor", @@ -11557,8 +12560,12 @@ "context-request", "delegated-state-reference", "engine-checkpoint", + "engine-durability-port", "engine-event", "engine-permission-decision", + "engine-permission-port", + "engine-post-commit-port", + "engine-tool-port", "event-journal-writer", "event-sink", "executor", @@ -11693,7 +12700,10 @@ "connection": "unknown" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -11710,7 +12720,10 @@ "sessionId": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "load", @@ -11720,7 +12733,10 @@ "sessionId": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "save", @@ -11729,7 +12745,104 @@ "checkpoint": "Checkpoint" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false + } + ] + }, + { + "name": "EngineDurabilityPort", + "group": "pipeline", + "symbol": "EngineDurabilityPort", + "source": "./pipeline/engine-durability-port", + "methods": [ + { + "name": "append", + "returns": "void", + "params": { + "event": "EngineEvent" + }, + "optional": false, + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false + }, + { + "name": "appendWithCheckpoint", + "returns": "void", + "params": { + "checkpoint": "EngineCheckpoint", + "events": "EngineEvent[]" + }, + "optional": false, + "sync": false, + "runtimeCancellable": false, + "atomic": true, + "nonFatal": false + } + ] + }, + { + "name": "EnginePermissionPort", + "group": "pipeline", + "symbol": "EnginePermissionPort", + "source": "./pipeline/engine-permission-port", + "methods": [ + { + "name": "authorize", + "returns": "EnginePermissionDecision", + "params": { + "request": "ModelToolRequest" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false + } + ] + }, + { + "name": "EnginePostCommitPort", + "group": "pipeline", + "symbol": "EnginePostCommitPort", + "source": "./pipeline/engine-post-commit-port", + "methods": [ + { + "name": "afterCommit", + "returns": "void", + "params": { + "commit": "TurnCommit", + "effectId": "string" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": true + } + ] + }, + { + "name": "EngineToolPort", + "group": "pipeline", + "symbol": "EngineToolPort", + "source": "./pipeline/engine-tool-port", + "methods": [ + { + "name": "execute", + "returns": "ModelToolResult", + "params": { + "request": "ModelToolRequest" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false } ] }, @@ -11746,7 +12859,10 @@ "sessionEvent": "SessionEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "appendTurn", @@ -11755,7 +12871,10 @@ "turnEvent": "TurnEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "close", @@ -11764,7 +12883,10 @@ "summary": "SessionSummary?" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -11781,7 +12903,10 @@ "sessionEvent": "SessionEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "emitTurn", @@ -11790,7 +12915,10 @@ "turnEvent": "TurnEvent" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -11808,7 +12936,10 @@ "messages": "Message[]" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false }, { "name": "executeStream", @@ -11818,7 +12949,10 @@ "messages": "Message[]" }, "optional": true, - "sync": false + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false }, { "name": "formatToolMessages", @@ -11830,7 +12964,10 @@ "toolResults": "string[]" }, "optional": false, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -11847,7 +12984,10 @@ "request": "HostToolRequest" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -11866,7 +13006,10 @@ "rendered": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "preRender", @@ -11875,7 +13018,10 @@ "template": "string" }, "optional": true, - "sync": true + "sync": true, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -11892,7 +13038,10 @@ "request": "PermissionRequest" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -11910,7 +13059,10 @@ "response": "unknown" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false }, { "name": "processStream", @@ -11919,7 +13071,10 @@ "stream": "unknown" }, "optional": true, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] }, @@ -11938,7 +13093,10 @@ "template": "string" }, "optional": false, - "sync": false + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false } ] } @@ -12021,8 +13179,12 @@ "./pipeline/context-request", "./pipeline/delegated-state-reference", "./pipeline/engine-checkpoint", + "./pipeline/engine-durability-port", "./pipeline/engine-event", "./pipeline/engine-permission-decision", + "./pipeline/engine-permission-port", + "./pipeline/engine-post-commit-port", + "./pipeline/engine-tool-port", "./pipeline/event-journal-writer", "./pipeline/event-sink", "./pipeline/executor", diff --git a/schema/tsp-output/.typra-generated/hydration-seams.json b/schema/tsp-output/.typra-generated/hydration-seams.json index aee225062..e29225b33 100644 --- a/schema/tsp-output/.typra-generated/hydration-seams.json +++ b/schema/tsp-output/.typra-generated/hydration-seams.json @@ -20,6 +20,38 @@ "generatedSource": "pipeline/CheckpointStore.cs", "seamKind": "protocol-adapter" }, + { + "contract": "EngineDurabilityPort", + "target": "csharp", + "group": "pipeline", + "symbol": "EngineDurabilityPort", + "generatedSource": "pipeline/EngineDurabilityPort.cs", + "seamKind": "protocol-adapter" + }, + { + "contract": "EnginePermissionPort", + "target": "csharp", + "group": "pipeline", + "symbol": "EnginePermissionPort", + "generatedSource": "pipeline/EnginePermissionPort.cs", + "seamKind": "protocol-adapter" + }, + { + "contract": "EnginePostCommitPort", + "target": "csharp", + "group": "pipeline", + "symbol": "EnginePostCommitPort", + "generatedSource": "pipeline/EnginePostCommitPort.cs", + "seamKind": "protocol-adapter" + }, + { + "contract": "EngineToolPort", + "target": "csharp", + "group": "pipeline", + "symbol": "EngineToolPort", + "generatedSource": "pipeline/EngineToolPort.cs", + "seamKind": "protocol-adapter" + }, { "contract": "EventJournalWriter", "target": "csharp", @@ -100,6 +132,38 @@ "generatedSource": "checkpoint_store.go", "seamKind": "protocol-adapter" }, + { + "contract": "EngineDurabilityPort", + "target": "go", + "group": "pipeline", + "symbol": "EngineDurabilityPort", + "generatedSource": "engine_durability_port.go", + "seamKind": "protocol-adapter" + }, + { + "contract": "EnginePermissionPort", + "target": "go", + "group": "pipeline", + "symbol": "EnginePermissionPort", + "generatedSource": "engine_permission_port.go", + "seamKind": "protocol-adapter" + }, + { + "contract": "EnginePostCommitPort", + "target": "go", + "group": "pipeline", + "symbol": "EnginePostCommitPort", + "generatedSource": "engine_post_commit_port.go", + "seamKind": "protocol-adapter" + }, + { + "contract": "EngineToolPort", + "target": "go", + "group": "pipeline", + "symbol": "EngineToolPort", + "generatedSource": "engine_tool_port.go", + "seamKind": "protocol-adapter" + }, { "contract": "EventJournalWriter", "target": "go", @@ -180,6 +244,38 @@ "generatedSource": "CheckpointStore", "seamKind": "protocol-adapter" }, + { + "contract": "EngineDurabilityPort", + "target": "markdown", + "group": "pipeline", + "symbol": "EngineDurabilityPort", + "generatedSource": "EngineDurabilityPort", + "seamKind": "protocol-adapter" + }, + { + "contract": "EnginePermissionPort", + "target": "markdown", + "group": "pipeline", + "symbol": "EnginePermissionPort", + "generatedSource": "EnginePermissionPort", + "seamKind": "protocol-adapter" + }, + { + "contract": "EnginePostCommitPort", + "target": "markdown", + "group": "pipeline", + "symbol": "EnginePostCommitPort", + "generatedSource": "EnginePostCommitPort", + "seamKind": "protocol-adapter" + }, + { + "contract": "EngineToolPort", + "target": "markdown", + "group": "pipeline", + "symbol": "EngineToolPort", + "generatedSource": "EngineToolPort", + "seamKind": "protocol-adapter" + }, { "contract": "EventJournalWriter", "target": "markdown", @@ -260,6 +356,38 @@ "generatedSource": ".pipeline", "seamKind": "protocol-adapter" }, + { + "contract": "EngineDurabilityPort", + "target": "python", + "group": "pipeline", + "symbol": "EngineDurabilityPort", + "generatedSource": ".pipeline", + "seamKind": "protocol-adapter" + }, + { + "contract": "EnginePermissionPort", + "target": "python", + "group": "pipeline", + "symbol": "EnginePermissionPort", + "generatedSource": ".pipeline", + "seamKind": "protocol-adapter" + }, + { + "contract": "EnginePostCommitPort", + "target": "python", + "group": "pipeline", + "symbol": "EnginePostCommitPort", + "generatedSource": ".pipeline", + "seamKind": "protocol-adapter" + }, + { + "contract": "EngineToolPort", + "target": "python", + "group": "pipeline", + "symbol": "EngineToolPort", + "generatedSource": ".pipeline", + "seamKind": "protocol-adapter" + }, { "contract": "EventJournalWriter", "target": "python", @@ -340,6 +468,38 @@ "generatedSource": "pipeline::checkpoint_store", "seamKind": "protocol-adapter" }, + { + "contract": "EngineDurabilityPort", + "target": "rust", + "group": "pipeline", + "symbol": "EngineDurabilityPort", + "generatedSource": "pipeline::engine_durability_port", + "seamKind": "protocol-adapter" + }, + { + "contract": "EnginePermissionPort", + "target": "rust", + "group": "pipeline", + "symbol": "EnginePermissionPort", + "generatedSource": "pipeline::engine_permission_port", + "seamKind": "protocol-adapter" + }, + { + "contract": "EnginePostCommitPort", + "target": "rust", + "group": "pipeline", + "symbol": "EnginePostCommitPort", + "generatedSource": "pipeline::engine_post_commit_port", + "seamKind": "protocol-adapter" + }, + { + "contract": "EngineToolPort", + "target": "rust", + "group": "pipeline", + "symbol": "EngineToolPort", + "generatedSource": "pipeline::engine_tool_port", + "seamKind": "protocol-adapter" + }, { "contract": "EventJournalWriter", "target": "rust", @@ -420,6 +580,38 @@ "generatedSource": "./pipeline/checkpoint-store", "seamKind": "protocol-adapter" }, + { + "contract": "EngineDurabilityPort", + "target": "typescript", + "group": "pipeline", + "symbol": "EngineDurabilityPort", + "generatedSource": "./pipeline/engine-durability-port", + "seamKind": "protocol-adapter" + }, + { + "contract": "EnginePermissionPort", + "target": "typescript", + "group": "pipeline", + "symbol": "EnginePermissionPort", + "generatedSource": "./pipeline/engine-permission-port", + "seamKind": "protocol-adapter" + }, + { + "contract": "EnginePostCommitPort", + "target": "typescript", + "group": "pipeline", + "symbol": "EnginePostCommitPort", + "generatedSource": "./pipeline/engine-post-commit-port", + "seamKind": "protocol-adapter" + }, + { + "contract": "EngineToolPort", + "target": "typescript", + "group": "pipeline", + "symbol": "EngineToolPort", + "generatedSource": "./pipeline/engine-tool-port", + "seamKind": "protocol-adapter" + }, { "contract": "EventJournalWriter", "target": "typescript", diff --git a/schema/tsp-output/.typra-generated/manifest.json b/schema/tsp-output/.typra-generated/manifest.json index 4581f6480..906c6b8b6 100644 --- a/schema/tsp-output/.typra-generated/manifest.json +++ b/schema/tsp-output/.typra-generated/manifest.json @@ -1278,6 +1278,11 @@ "path": "../runtime/csharp/Prompty.Core/Model/pipeline/EngineCheckpoint.cs", "marker": true }, + { + "outputRoot": "../runtime/csharp/Prompty.Core/Model", + "path": "../runtime/csharp/Prompty.Core/Model/pipeline/EngineDurabilityPort.cs", + "marker": true + }, { "outputRoot": "../runtime/csharp/Prompty.Core/Model", "path": "../runtime/csharp/Prompty.Core/Model/pipeline/EngineEvent.cs", @@ -1293,6 +1298,21 @@ "path": "../runtime/csharp/Prompty.Core/Model/pipeline/EnginePermissionDecision.cs", "marker": true }, + { + "outputRoot": "../runtime/csharp/Prompty.Core/Model", + "path": "../runtime/csharp/Prompty.Core/Model/pipeline/EnginePermissionPort.cs", + "marker": true + }, + { + "outputRoot": "../runtime/csharp/Prompty.Core/Model", + "path": "../runtime/csharp/Prompty.Core/Model/pipeline/EnginePostCommitPort.cs", + "marker": true + }, + { + "outputRoot": "../runtime/csharp/Prompty.Core/Model", + "path": "../runtime/csharp/Prompty.Core/Model/pipeline/EngineToolPort.cs", + "marker": true + }, { "outputRoot": "../runtime/csharp/Prompty.Core/Model", "path": "../runtime/csharp/Prompty.Core/Model/pipeline/EngineTurnStatus.cs", @@ -1938,6 +1958,11 @@ "path": "../runtime/go/prompty/model/engine_checkpoint.go", "marker": true }, + { + "outputRoot": "../runtime/go/prompty/model", + "path": "../runtime/go/prompty/model/engine_durability_port.go", + "marker": true + }, { "outputRoot": "../runtime/go/prompty/model", "path": "../runtime/go/prompty/model/engine_event_test.go", @@ -1958,6 +1983,21 @@ "path": "../runtime/go/prompty/model/engine_permission_decision.go", "marker": true }, + { + "outputRoot": "../runtime/go/prompty/model", + "path": "../runtime/go/prompty/model/engine_permission_port.go", + "marker": true + }, + { + "outputRoot": "../runtime/go/prompty/model", + "path": "../runtime/go/prompty/model/engine_post_commit_port.go", + "marker": true + }, + { + "outputRoot": "../runtime/go/prompty/model", + "path": "../runtime/go/prompty/model/engine_tool_port.go", + "marker": true + }, { "outputRoot": "../runtime/go/prompty/model", "path": "../runtime/go/prompty/model/error_chunk_test.go", @@ -3488,6 +3528,11 @@ "path": "../runtime/python/prompty/prompty/model/pipeline/_EngineCheckpoint.py", "marker": true }, + { + "outputRoot": "../runtime/python/prompty/prompty/model", + "path": "../runtime/python/prompty/prompty/model/pipeline/_EngineDurabilityPort.py", + "marker": true + }, { "outputRoot": "../runtime/python/prompty/prompty/model", "path": "../runtime/python/prompty/prompty/model/pipeline/_EngineEvent.py", @@ -3498,6 +3543,21 @@ "path": "../runtime/python/prompty/prompty/model/pipeline/_EnginePermissionDecision.py", "marker": true }, + { + "outputRoot": "../runtime/python/prompty/prompty/model", + "path": "../runtime/python/prompty/prompty/model/pipeline/_EnginePermissionPort.py", + "marker": true + }, + { + "outputRoot": "../runtime/python/prompty/prompty/model", + "path": "../runtime/python/prompty/prompty/model/pipeline/_EnginePostCommitPort.py", + "marker": true + }, + { + "outputRoot": "../runtime/python/prompty/prompty/model", + "path": "../runtime/python/prompty/prompty/model/pipeline/_EngineToolPort.py", + "marker": true + }, { "outputRoot": "../runtime/python/prompty/prompty/model", "path": "../runtime/python/prompty/prompty/model/pipeline/_EventJournalWriter.py", @@ -4838,6 +4898,11 @@ "path": "../runtime/rust/prompty/src/model/pipeline/engine_checkpoint.rs", "marker": true }, + { + "outputRoot": "../runtime/rust/prompty/src/model", + "path": "../runtime/rust/prompty/src/model/pipeline/engine_durability_port.rs", + "marker": true + }, { "outputRoot": "../runtime/rust/prompty/src/model", "path": "../runtime/rust/prompty/src/model/pipeline/engine_event.rs", @@ -4848,6 +4913,21 @@ "path": "../runtime/rust/prompty/src/model/pipeline/engine_permission_decision.rs", "marker": true }, + { + "outputRoot": "../runtime/rust/prompty/src/model", + "path": "../runtime/rust/prompty/src/model/pipeline/engine_permission_port.rs", + "marker": true + }, + { + "outputRoot": "../runtime/rust/prompty/src/model", + "path": "../runtime/rust/prompty/src/model/pipeline/engine_post_commit_port.rs", + "marker": true + }, + { + "outputRoot": "../runtime/rust/prompty/src/model", + "path": "../runtime/rust/prompty/src/model/pipeline/engine_tool_port.rs", + "marker": true + }, { "outputRoot": "../runtime/rust/prompty/src/model", "path": "../runtime/rust/prompty/src/model/pipeline/event_journal_writer.rs", @@ -6278,6 +6358,11 @@ "path": "../runtime/typescript/packages/core/src/model/pipeline/engine-checkpoint.ts", "marker": true }, + { + "outputRoot": "../runtime/typescript/packages/core/src/model", + "path": "../runtime/typescript/packages/core/src/model/pipeline/engine-durability-port.ts", + "marker": true + }, { "outputRoot": "../runtime/typescript/packages/core/src/model", "path": "../runtime/typescript/packages/core/src/model/pipeline/engine-event.ts", @@ -6288,6 +6373,21 @@ "path": "../runtime/typescript/packages/core/src/model/pipeline/engine-permission-decision.ts", "marker": true }, + { + "outputRoot": "../runtime/typescript/packages/core/src/model", + "path": "../runtime/typescript/packages/core/src/model/pipeline/engine-permission-port.ts", + "marker": true + }, + { + "outputRoot": "../runtime/typescript/packages/core/src/model", + "path": "../runtime/typescript/packages/core/src/model/pipeline/engine-post-commit-port.ts", + "marker": true + }, + { + "outputRoot": "../runtime/typescript/packages/core/src/model", + "path": "../runtime/typescript/packages/core/src/model/pipeline/engine-tool-port.ts", + "marker": true + }, { "outputRoot": "../runtime/typescript/packages/core/src/model", "path": "../runtime/typescript/packages/core/src/model/pipeline/event-journal-writer.ts", @@ -7488,6 +7588,11 @@ "path": "../web/src/content/docs/reference/EngineCheckpoint.md", "marker": true }, + { + "outputRoot": "../web/src/content/docs/reference", + "path": "../web/src/content/docs/reference/EngineDurabilityPort.md", + "marker": true + }, { "outputRoot": "../web/src/content/docs/reference", "path": "../web/src/content/docs/reference/EngineEvent.md", @@ -7498,6 +7603,21 @@ "path": "../web/src/content/docs/reference/EnginePermissionDecision.md", "marker": true }, + { + "outputRoot": "../web/src/content/docs/reference", + "path": "../web/src/content/docs/reference/EnginePermissionPort.md", + "marker": true + }, + { + "outputRoot": "../web/src/content/docs/reference", + "path": "../web/src/content/docs/reference/EnginePostCommitPort.md", + "marker": true + }, + { + "outputRoot": "../web/src/content/docs/reference", + "path": "../web/src/content/docs/reference/EngineToolPort.md", + "marker": true + }, { "outputRoot": "../web/src/content/docs/reference", "path": "../web/src/content/docs/reference/ErrorChunk.md", diff --git a/schema/tsp-output/json-ast/model.json b/schema/tsp-output/json-ast/model.json index a67546c6f..41f9548b2 100644 --- a/schema/tsp-output/json-ast/model.json +++ b/schema/tsp-output/json-ast/model.json @@ -7,6 +7,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -35,7 +36,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -65,7 +68,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -95,7 +100,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -108,7 +115,7 @@ "namespace": "", "name": "dictionary" }, - "description": "Additional metadata including authors, tags, and other arbitrary properties", + "description": "Additional metadata including authors, tags, and other arbitrary properties. Values may be explicit null.", "samples": [ { "sample": { @@ -134,7 +141,9 @@ "isCollection": false, "isAny": false, "isDict": true, + "dictValueType": "unknown", "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -200,7 +209,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -215,6 +226,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": "default", "discriminator": "kind", "coercions": [ { @@ -270,6 +282,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -290,7 +303,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "array", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -318,11 +333,13 @@ "knownAs": [], "defaultFor": [], "isScalar": false, - "isOptional": false, + "isOptional": true, "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -337,6 +354,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": "default", "discriminator": "kind", "coercions": [ { @@ -392,6 +410,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -412,7 +431,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "object", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -449,7 +470,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -464,6 +487,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": "default", "discriminator": "kind", "coercions": [ { @@ -519,6 +543,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -539,7 +564,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "union", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -561,7 +588,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -576,6 +605,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": "default", "discriminator": "kind", "coercions": [ { @@ -643,7 +673,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [ "string", "integer", @@ -685,7 +717,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -715,7 +749,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -745,7 +781,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -775,7 +813,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -805,7 +845,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -839,7 +881,9 @@ "isCollection": true, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -879,7 +923,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -894,6 +940,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": "default", "discriminator": "kind", "coercions": [ { @@ -961,7 +1008,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [ "string", "integer", @@ -1003,7 +1052,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1033,7 +1084,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1063,7 +1116,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1093,7 +1148,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1123,7 +1180,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1157,7 +1216,9 @@ "isCollection": true, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1194,7 +1255,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1224,7 +1287,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [ "string", "integer", @@ -1266,7 +1331,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1296,7 +1363,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1326,7 +1395,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1356,7 +1427,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1386,7 +1459,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1420,7 +1495,9 @@ "isCollection": true, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1444,6 +1521,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -1464,7 +1542,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "union", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1486,7 +1566,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1501,6 +1583,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": "default", "discriminator": "kind", "coercions": [ { @@ -1568,7 +1651,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [ "string", "integer", @@ -1610,7 +1695,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1640,7 +1727,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1670,7 +1759,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1700,7 +1791,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1730,7 +1823,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1764,7 +1859,9 @@ "isCollection": true, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1804,7 +1901,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1819,6 +1918,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": "default", "discriminator": "kind", "coercions": [ { @@ -1886,7 +1986,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [ "string", "integer", @@ -1928,7 +2030,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1958,7 +2062,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -1988,7 +2094,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2018,7 +2126,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2048,7 +2158,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2082,7 +2194,9 @@ "isCollection": true, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2119,7 +2233,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [ "string", "integer", @@ -2161,7 +2277,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2191,7 +2309,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2221,7 +2341,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2251,7 +2373,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2281,7 +2405,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2315,7 +2441,9 @@ "isCollection": true, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2339,6 +2467,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -2359,7 +2488,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "object", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2396,7 +2527,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2417,6 +2550,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -2437,7 +2571,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "union", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2459,7 +2595,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2474,6 +2612,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": "default", "discriminator": "kind", "coercions": [ { @@ -2541,7 +2680,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [ "string", "integer", @@ -2583,7 +2724,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2613,7 +2756,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2643,7 +2788,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2673,7 +2820,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2703,7 +2852,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2737,7 +2888,9 @@ "isCollection": true, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2777,7 +2930,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2792,6 +2947,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": "default", "discriminator": "kind", "coercions": [ { @@ -2859,7 +3015,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [ "string", "integer", @@ -2901,7 +3059,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2931,7 +3091,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2961,7 +3123,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -2991,7 +3155,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3021,7 +3187,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3055,7 +3223,9 @@ "isCollection": true, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3092,7 +3262,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3122,7 +3294,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [ "string", "integer", @@ -3164,7 +3338,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3194,7 +3370,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3224,7 +3402,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3254,7 +3434,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3284,7 +3466,9 @@ "isCollection": false, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3318,7 +3502,9 @@ "isCollection": true, "isAny": true, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3369,7 +3555,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3402,11 +3590,13 @@ "knownAs": [], "defaultFor": [], "isScalar": false, - "isOptional": false, + "isOptional": true, "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3421,6 +3611,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [ { "scalar": "string", @@ -3458,7 +3649,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3488,7 +3681,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3518,7 +3713,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [ "chat", "embedding", @@ -3557,7 +3754,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3572,6 +3771,7 @@ "base": {}, "isAbstract": true, "isProtocol": false, + "entryShorthand": null, "discriminator": "kind", "coercions": [], "factories": [], @@ -3589,6 +3789,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -3617,7 +3818,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "reference", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3647,7 +3850,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3677,7 +3882,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3698,6 +3905,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -3726,7 +3934,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "remote", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3756,7 +3966,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3786,7 +3998,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3807,6 +4021,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -3835,7 +4050,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "key", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3865,7 +4082,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3895,7 +4114,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3916,6 +4137,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -3944,7 +4166,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "anonymous", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3974,7 +4198,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -3995,6 +4221,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -4023,7 +4250,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "oauth", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4053,7 +4282,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4083,7 +4314,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4113,7 +4346,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4143,7 +4378,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4175,7 +4412,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4196,6 +4435,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -4224,7 +4464,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "foundry", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4254,7 +4496,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4284,7 +4528,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4314,7 +4560,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4348,17 +4596,12 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", - "allowedValues": [ - "remote", - "reference", - "key", - "anonymous", - "foundry", - "oauth" - ], + "hasExplicitDefault": false, + "allowedValues": [], "parseAliases": {}, - "enumName": "ConnectionType", + "enumName": null, "isOpenEnum": false, "isNamedCollection": false }, @@ -4385,7 +4628,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [ "user", "system" @@ -4418,7 +4663,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4455,7 +4702,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4470,6 +4719,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -4503,7 +4753,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4546,7 +4798,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4581,7 +4835,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4616,7 +4872,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4659,7 +4917,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4698,7 +4958,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4741,7 +5003,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4783,7 +5047,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4818,7 +5084,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4851,7 +5119,9 @@ "isCollection": false, "isAny": false, "isDict": true, + "dictValueType": "unknown", "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4925,7 +5195,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -4940,6 +5212,7 @@ "base": {}, "isAbstract": true, "isProtocol": false, + "entryShorthand": null, "discriminator": "kind", "coercions": [], "factories": [], @@ -4957,6 +5230,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -4985,7 +5259,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "function", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5051,7 +5327,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5081,7 +5359,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": true, + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5102,6 +5382,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -5122,7 +5403,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "*", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5154,7 +5437,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5187,7 +5472,9 @@ "isCollection": false, "isAny": false, "isDict": true, + "dictValueType": "unknown", "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5208,6 +5495,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -5236,7 +5524,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "mcp", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5268,7 +5558,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5298,7 +5590,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5328,7 +5622,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5356,11 +5652,13 @@ "knownAs": [], "defaultFor": [], "isScalar": false, - "isOptional": false, + "isOptional": true, "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5375,6 +5673,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [ { "scalar": "string", @@ -5413,7 +5712,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [ "always", "never", @@ -5449,7 +5750,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5481,7 +5784,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5517,7 +5822,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5538,6 +5845,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -5566,7 +5874,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "openapi", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5598,7 +5908,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5628,7 +5940,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5649,6 +5963,7 @@ }, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -5677,7 +5992,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "prompty", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5707,7 +6024,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5737,7 +6056,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "single", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5771,7 +6092,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5801,7 +6124,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [ "function", "mcp", @@ -5836,7 +6161,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5868,7 +6195,9 @@ "isCollection": true, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5883,6 +6212,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [ { "scalar": "string", @@ -5920,7 +6250,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5950,7 +6282,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -5989,7 +6323,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -6004,6 +6340,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "coercions": [], "factories": [], "methods": [], @@ -6034,7 +6371,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -6049,6 +6388,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "discriminator": "kind", "coercions": [ { @@ -6087,7 +6427,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "*", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -6117,7 +6459,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -6149,7 +6493,9 @@ "isCollection": false, "isAny": false, "isDict": true, + "dictValueType": "unknown", "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -6184,7 +6530,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": false, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -6199,6 +6547,7 @@ "base": {}, "isAbstract": false, "isProtocol": false, + "entryShorthand": null, "discriminator": "kind", "coercions": [ { @@ -6237,7 +6586,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "*", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -6269,7 +6620,9 @@ "isCollection": false, "isAny": false, "isDict": true, + "dictValueType": "unknown", "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, @@ -6305,7 +6658,9 @@ "isCollection": false, "isAny": false, "isDict": false, + "dictValueType": null, "defaultValue": "null", + "hasExplicitDefault": true, "allowedValues": [], "parseAliases": {}, "enumName": null, diff --git a/schema/tspconfig.yaml b/schema/tspconfig.yaml index a4be8b639..e251a9b3e 100644 --- a/schema/tspconfig.yaml +++ b/schema/tspconfig.yaml @@ -19,6 +19,14 @@ options: output-dir: "../runtime/python/prompty/prompty/model" test-dir: "../runtime/python/prompty/tests/model" import-path: "prompty.model" + # Relative, not absolute. Flit resolves the dynamic __version__ by loading + # prompty/__init__.py via spec_from_file_location, so "prompty" is not on + # sys.path during the build. An absolute self-import (the emitter default, + # "prompty.core.cancellation.CancellationToken") therefore raises + # ModuleNotFoundError and makes the package uninstallable from a clean env. + # Generated runtime-cancellable ports live in prompty/model/pipeline/, so + # "..." resolves to the prompty package root. + cancellation-token-path: "...core.cancellation.CancellationToken" - type: CSharp output-dir: "../runtime/csharp/Prompty.Core/Model" test-dir: "../runtime/csharp/Prompty.Core.Tests/Model" diff --git a/spec/spec.md b/spec/spec.md index 67c554f69..76fef3c8c 100644 --- a/spec/spec.md +++ b/spec/spec.md @@ -246,6 +246,47 @@ correspond to the TypeSpec-generated data model. Unknown top-level properties SHOULD be preserved in `metadata` or ignored. Implementations MUST NOT raise an error for unknown properties. +#### `Record` value nullability + +`Record` has two independent nullability axes: + +1. The model property's optionality controls whether the record itself may be absent. +2. The `unknown` value type permits every JSON-compatible value, including explicit + `null`, at any nesting depth. + +After YAML parsing, YAML null forms have the same explicit-null semantics as JSON +`null`. + +An implementation MUST preserve null-valued keys through load → save → reload. It MUST +NOT drop a key whose value is `null`, coerce that value to an empty object or another +sentinel, or conflate the present-null state with an absent key. Nested objects and arrays +MUST apply the same rule recursively. + +This contract applies to every `Record` surface. The following generated fields +are specifically covered by the shared acceptance vectors because they cross canonical +runtime boundaries: + +| Model | Field | Record presence | +| ----- | ----- | --------------- | +| `Message` | `metadata` | Required | +| `Prompty` | `metadata` | Optional | +| `ModelInfo` | `additionalProperties` | Optional | +| `TurnModelRequest` | `inputs` | Optional | +| `RunTurnRequest` | `inputs` | Optional | +| `TurnModelResponse` | `checkpointState` | Optional | +| `HostToolRequest` | `arguments` | Optional | +| `TurnEvent` | `payload` | Required | +| `SessionEvent` | `payload` | Required | + +Language bindings MUST retain both axes. For example, the conforming C# mapping is +`IDictionary` for a required record and +`IDictionary?` for an optional record. Mapping a +`Record` value as non-null `object` changes the schema contract and is +non-conformant. Nullable annotations do not add a wire or model field. + +The normative shared vectors are +`spec/vectors/model/record_unknown_nullability_vectors.json`. + ### §2.4 Model The `model` property configures the LLM provider and parameters. @@ -285,6 +326,20 @@ Connection types are discriminated by the `kind` field: | `foundry` | `endpoint` | Microsoft Foundry connection | | `oauth` | `endpoint`, `authenticationMode` | OAuth-based authentication | +The `kind` discriminator is open for forward compatibility. Known-kind matching is +exact and case-sensitive, so a value such as `Reference` is an unknown kind rather than +the known `reference` kind. When a runtime loads a connection whose string `kind` is +not listed above, it MUST preserve that exact discriminator and every JSON-compatible +property in the connection payload through a load → save → reload cycle. +Implementations MUST NOT coerce an unknown connection to a known/default connection +kind or discard its additional payload. This passthrough requirement applies to unknown +connection kinds; known connection kinds retain their schema-defined fields. The shared +acceptance vector is `spec/vectors/model/connection_roundtrip_vectors.json`. + +This contract is independent of tool dispatch. An unknown tool `kind` continues to load +as `CustomTool` under §2.9; an unknown connection remains an unknown `Connection` and +does not imply `CustomTool`. + ### §2.6 ModelOptions | Property | Type | Description | @@ -332,8 +387,62 @@ schema is a `Property` object: Rich kinds (`thread`, `image`, `file`, `audio`) receive special handling during rendering — see §5 for details. +#### Named collection encoding + +Named collections such as `Properties`, `Tools`, `Bindings`, and `Connections` accept +either a flat array of entries or a name-keyed object. Names are opaque parsed strings: +implementations MUST NOT trim, case-fold, or Unicode-normalize them before comparison. +Missing `name` and an explicit empty `name` are the same unnamed state because `name` +defaults to `""`. + +The canonical serialization is a name-keyed object when every entry has a non-empty +name and all names are unique by exact parsed-string comparison. Otherwise, the +serializer MUST encode the entire collection as an array, preserving entry order and +every entry's payload. The canonical array form MUST omit an empty `name`. An explicit +array-format option MAY force array encoding for a losslessly object-encodable +collection, but an object-format option MUST NOT override the lossless fallback. + +Implementations MUST NOT omit unnamed entries, overwrite duplicate names, or invent +synthetic keys such as `_unnamed`, indexes, or suffixed names. Loading either canonical +form and then saving and reloading it MUST preserve the same entries and payloads. +Array fallback MUST preserve model entry order; object ordering is not semantically +significant. + +At every named-collection boundary, recursively: + +- The array form is the collection itself: a flat array of entries. +- In the name-keyed object form, each key maps to exactly one entry. +- An array used as the immediate value of a name-keyed entry is structurally invalid. + It MUST be rejected at the first invalid value and MUST NOT be skipped, flattened, + stringified, or coerced into a default entry. +- The native load error MUST identify the full collection path, including the offending + key, and identify the invalid value category as `array`. + +This validation is schema-aware and applies after JSON/YAML parsing and reference +resolution. It does not reject the outer flat array form or arrays in declared fields +inside a valid entry, such as `Property.default`. In particular, list shorthand is not +available as the immediate value of a name-keyed `Property` entry because it is +ambiguous with an invalid nested collection. Use the expanded form instead: + +```yaml +inputs: + aliases: + kind: array + default: [Ada, Grace] +``` + +The normative load/save/reload and rejection cases are +`spec/vectors/model/named_collection_vectors.json`. + **Scalar shorthand**: When a property value is a plain scalar instead of a `Property` object, it MUST be interpreted as `Property(kind: , default: )`. +This applies to every immediate string, integer, float, or boolean value in the +name-keyed object form of a `Property` collection. The object key supplies `name`. +Implementations MUST NOT reinterpret the scalar as the `kind` field, reject a valid +primitive scalar, or silently produce an empty `kind`. At a name-keyed collection +boundary, this normalization to `default` MUST occur before direct generated-model +`@coerce` handling; the direct-coercion `example` behavior MUST NOT apply to the +collection entry, and `example` MUST remain unset. ```yaml # Shorthand @@ -349,6 +458,16 @@ inputs: Kind inference from scalar type: string → `"string"`, integer → `"integer"`, float → `"float"`, boolean → `"boolean"`, list → `"array"`, dict → `"object"`. +The named-collection object-form exception for list values is defined above. + +This named-collection shorthand is distinct from direct generated-model coercion. +When a generated `Property` loader receives a scalar as its complete input, the +TypeSpec `@coerce` contract MUST infer the same scalar kind and store the scalar in +`example`; it MUST NOT drop or coerce the value. JSON integer and fractional number +inputs MUST remain distinguishable as `"integer"` and `"float"` respectively. The +four primitive scalar branches are an atomic contract: string, integer, float, and +boolean MUST all be supported. The normative acceptance vector is +`spec/vectors/model/property_scalar_coercion_vectors.json`. ### §2.8 Template @@ -415,13 +534,12 @@ tools: kind: function description: Get orders for a user parameters: - properties: - - name: user_id - kind: string - required: true - - name: limit - kind: integer - default: 10 + - name: user_id + kind: string + required: true + - name: limit + kind: integer + default: 10 bindings: user_id: ${env:CURRENT_USER_ID} ``` @@ -1271,6 +1389,20 @@ ToolResult: parts: ContentPart[] // Rich content from tool execution ``` +`ContentPart` is a closed, exact, case-sensitive discriminated union. A typed +`ContentPart` load MUST reject any `kind` other than `text`, `image`, `audio`, or +`file`, including case-only variants such as `Text`. Implementations MUST NOT create an +unknown fallback variant, preserve the unknown payload as a `ContentPart`, or coerce it +to a known/default kind. The rejection MUST be classified as an unknown-discriminator +load error and report both the discriminator field (`kind`) and the exact offending +string value. Runtimes MAY use their native load-error type, but its diagnostic or +structured data MUST expose equivalent information. + +This strict contract is distinct from the intentionally open discriminator contracts: +an unknown tool kind loads as `CustomTool` under §2.9, and an unknown connection kind +is preserved under §2.5. The shared acceptance vectors are in +`spec/vectors/model/content_part_discriminator_vectors.json`. + **ToolResult** enables tools to return rich content (text, images, files, audio) rather than plain strings. Implementations MUST support conversion from a plain string to a `ToolResult` containing a single `TextPart` for backward compatibility diff --git a/spec/turn-engine.md b/spec/turn-engine.md index 93bc65fa6..263874b6a 100644 --- a/spec/turn-engine.md +++ b/spec/turn-engine.md @@ -250,24 +250,29 @@ post-commit start event prevents the effect from running. Failure to persist com after the effect ran is returned as non-fatal recovery information and MUST NOT make the committed turn appear to have failed. -## Runtime-Local Ports - -Native runtime interfaces own async, streaming, cancellation, and SDK-specific behavior: - -- `ModelPort` -- `ContextSource` -- `ContextTransform` -- `ContextPackingStrategy` -- `PermissionPort` -- `ToolPort` -- `DurabilityPort` (atomic semantic-event and checkpoint persistence) -- `Clock` -- `IdGenerator` -- post-commit effect ports - -Portable TypeSpec models will be promoted only after the Rust state machine and -conformance vectors establish stable semantics. Native interfaces themselves are not -generated. +## Engine Ports + +The stable permission, tool, durability, and post-commit effect boundaries are canonical +TypeSpec protocols: + +- `EnginePermissionPort.authorize(ModelToolRequest) -> EnginePermissionDecision` is async + and runtime-cancellable. +- `EngineToolPort.execute(ModelToolRequest) -> ModelToolResult` is async and + runtime-cancellable. +- `EngineDurabilityPort.append(EngineEvent) -> void` is async and non-cancellable. +- `EngineDurabilityPort.appendWithCheckpoint(EngineEvent[], EngineCheckpoint) -> void` + is async, atomic, and non-cancellable. +- `EnginePostCommitPort.afterCommit(effectId, TurnCommit) -> void` is async, + runtime-cancellable, and non-fatal after the turn is committed. + +Runtime cancellation is projected as a native language seam rather than a wire or model +field: `CancellationToken` in C#, `context.Context` in Go, optional `AbortSignal` in +TypeScript, the runtime `CancellationToken` reference in Rust, and the runtime +`CancellationToken` signal in Python. Port failures remain native runtime errors such as +`PortError`; they are not portable wire models. + +Richer runtime interfaces continue to own SDK-specific streaming, context assembly, +host policy, retry, clocks, identifiers, and provider reconciliation behavior. ## Rust-First Conformance Gate diff --git a/spec/vectors/agent/agent_vectors.json b/spec/vectors/agent/agent_vectors.json index 4401d7ff0..35e1b5ea1 100644 --- a/spec/vectors/agent/agent_vectors.json +++ b/spec/vectors/agent/agent_vectors.json @@ -18,15 +18,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -79,15 +77,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -219,15 +215,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -393,39 +387,35 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city (returns Fahrenheit)", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] }, { "name": "convert_temperature", "kind": "function", "description": "Convert a temperature between Fahrenheit and Celsius", - "parameters": { - "properties": [ - { - "name": "value", - "kind": "float", - "required": true - }, - { - "name": "from_unit", - "kind": "string", - "required": true - }, - { - "name": "to_unit", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "value", + "kind": "float", + "required": true + }, + { + "name": "from_unit", + "kind": "string", + "required": true + }, + { + "name": "to_unit", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -565,15 +555,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -1071,20 +1059,18 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - }, - { - "name": "unit", - "kind": "string", - "required": false - } - ] - }, + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + }, + { + "name": "unit", + "kind": "string", + "required": false + } + ], "bindings": { "unit": { "input": "preferred_unit" @@ -1191,15 +1177,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -1268,15 +1252,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -1382,15 +1364,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -1527,9 +1507,7 @@ "name": "clear_cache", "kind": "function", "description": "Clear the application cache, returns empty on success", - "parameters": { - "properties": [] - } + "parameters": [] } ], "tool_functions": { @@ -1635,15 +1613,13 @@ "name": "lookup", "kind": "function", "description": "Look up data", - "parameters": { - "properties": [ - { - "name": "query", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "query", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -1738,15 +1714,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -1877,15 +1851,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -1948,15 +1920,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -2067,15 +2037,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -2121,15 +2089,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -2253,15 +2219,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -2408,15 +2372,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -2527,15 +2489,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -2611,15 +2571,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -2691,15 +2649,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -2740,15 +2696,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -2811,29 +2765,25 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] }, { "name": "dangerous_tool", "kind": "function", "description": "A dangerous operation that should be guarded", - "parameters": { - "properties": [ - { - "name": "target", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "target", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -2960,15 +2910,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -3076,15 +3024,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -3277,15 +3223,13 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { @@ -3440,37 +3384,31 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] }, { "name": "get_time", "kind": "function", "description": "Get the current time in a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] }, { "name": "get_news", "kind": "function", "description": "Get the latest news headlines", - "parameters": { - "properties": [] - } + "parameters": [] } ], "tool_functions": { @@ -3611,43 +3549,37 @@ "name": "get_weather", "kind": "function", "description": "Get the current weather for a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] }, { "name": "get_time", "kind": "function", "description": "Get the current time in a city", - "parameters": { - "properties": [ - { - "name": "city", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "city", + "kind": "string", + "required": true + } + ] }, { "name": "dangerous_tool", "kind": "function", "description": "A dangerous operation", - "parameters": { - "properties": [ - { - "name": "target", - "kind": "string", - "required": true - } - ] - } + "parameters": [ + { + "name": "target", + "kind": "string", + "required": true + } + ] } ], "tool_functions": { diff --git a/spec/vectors/engine/port_contracts.json b/spec/vectors/engine/port_contracts.json new file mode 100644 index 000000000..0ed4a1cc5 --- /dev/null +++ b/spec/vectors/engine/port_contracts.json @@ -0,0 +1,122 @@ +{ + "version": "1", + "legacyHarnessSha256": "c534140f1aae07034cedea13feec3d66a9acbc0df46869858753aef39e24fe69", + "nativeErrors": ["PortError"], + "protocols": { + "EnginePermissionPort": { + "methods": { + "authorize": { + "returns": "EnginePermissionDecision", + "params": { + "request": "ModelToolRequest" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false + } + } + }, + "EngineToolPort": { + "methods": { + "execute": { + "returns": "ModelToolResult", + "params": { + "request": "ModelToolRequest" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": false + } + } + }, + "EngineDurabilityPort": { + "methods": { + "append": { + "returns": "void", + "params": { + "event": "EngineEvent" + }, + "optional": false, + "sync": false, + "runtimeCancellable": false, + "atomic": false, + "nonFatal": false + }, + "appendWithCheckpoint": { + "returns": "void", + "params": { + "events": "EngineEvent[]", + "checkpoint": "EngineCheckpoint" + }, + "optional": false, + "sync": false, + "runtimeCancellable": false, + "atomic": true, + "nonFatal": false + } + } + }, + "EnginePostCommitPort": { + "methods": { + "afterCommit": { + "returns": "void", + "params": { + "effectId": "string", + "commit": "TurnCommit" + }, + "optional": false, + "sync": false, + "runtimeCancellable": true, + "atomic": false, + "nonFatal": true + } + } + }, + "Executor": { + "methods": { + "execute": { + "returns": "unknown", + "params": { + "agent": "Prompty", + "messages": "Message[]" + }, + "optional": false, + "runtimeCancellable": true, + "sync": false, + "atomic": false, + "nonFatal": false + }, + "executeStream": { + "returns": "unknown", + "params": { + "agent": "Prompty", + "messages": "Message[]" + }, + "optional": true, + "runtimeCancellable": true, + "sync": false, + "atomic": false, + "nonFatal": false + }, + "formatToolMessages": { + "returns": "Message[]", + "params": { + "rawResponse": "unknown", + "toolCalls": "ToolCall[]", + "toolResults": "string[]", + "textContent": "string?" + }, + "optional": false, + "runtimeCancellable": false, + "sync": true, + "atomic": false, + "nonFatal": false + } + } + } + } +} diff --git a/spec/vectors/model/connection_roundtrip_vectors.json b/spec/vectors/model/connection_roundtrip_vectors.json new file mode 100644 index 000000000..0336c31ad --- /dev/null +++ b/spec/vectors/model/connection_roundtrip_vectors.json @@ -0,0 +1,106 @@ +{ + "version": "1", + "description": "Cross-runtime Connection load/save/reload contracts. Unknown string discriminators are forward-compatible Connection values, not CustomTool values: runtimes must preserve the exact kind and complete JSON-compatible payload without coercing to a known/default connection kind.", + "vectors": [ + { + "name": "known_reference_connection_roundtrip_unchanged", + "operation": "load-save-reload", + "input": { + "kind": "reference", + "authenticationMode": "system", + "usageDescription": "Exercise the known discriminator control", + "name": "shared-connection", + "target": "model-service" + }, + "expected": { + "kind": "reference", + "authenticationMode": "system", + "usageDescription": "Exercise the known discriminator control", + "name": "shared-connection", + "target": "model-service" + } + }, + { + "name": "unknown_connection_kind_preserves_payload", + "operation": "load-save-reload", + "input": { + "kind": "future-auth", + "authenticationMode": "system", + "usageDescription": "Exercise forward-compatible connection preservation", + "endpoint": "https://future.example.test", + "tenant": "example-tenant", + "priority": 5, + "enabled": false, + "weight": 0.1, + "regions": [ + "west", + "east" + ], + "notes": null, + "providerOptions": { + "audience": "prompty", + "features": [ + "delegation", + { + "name": "nested-option", + "enabled": true + } + ], + "retry": { + "maxAttempts": 3, + "backoffSeconds": 0.1 + }, + "nullable": null + } + }, + "expected": { + "kind": "future-auth", + "authenticationMode": "system", + "usageDescription": "Exercise forward-compatible connection preservation", + "endpoint": "https://future.example.test", + "tenant": "example-tenant", + "priority": 5, + "enabled": false, + "weight": 0.1, + "regions": [ + "west", + "east" + ], + "notes": null, + "providerOptions": { + "audience": "prompty", + "features": [ + "delegation", + { + "name": "nested-option", + "enabled": true + } + ], + "retry": { + "maxAttempts": 3, + "backoffSeconds": 0.1 + }, + "nullable": null + } + } + }, + { + "name": "unknown_connection_case_collision_preserves_payload", + "operation": "load-save-reload", + "input": { + "kind": "Reference", + "name": "case-sensitive-unknown", + "payload": { + "mode": "future" + } + }, + "expected": { + "kind": "Reference", + "name": "case-sensitive-unknown", + "payload": { + "mode": "future" + } + } + } + ] +} diff --git a/spec/vectors/model/content_part_discriminator_vectors.json b/spec/vectors/model/content_part_discriminator_vectors.json new file mode 100644 index 000000000..41c237dbd --- /dev/null +++ b/spec/vectors/model/content_part_discriminator_vectors.json @@ -0,0 +1,45 @@ +{ + "version": "1", + "description": "Cross-runtime strict ContentPart discriminator contracts. ContentPart is closed and case-sensitive: unknown kinds are rejected rather than preserved like unknown Connection values or dispatched like unknown Tool values.", + "vectors": [ + { + "name": "known_text_content_part_loads", + "operation": "load", + "input": { + "kind": "text", + "value": "hello" + }, + "expected": { + "kind": "text", + "value": "hello" + } + }, + { + "name": "unknown_content_part_kind_is_rejected", + "operation": "load-error", + "input": { + "kind": "video", + "source": "https://example.test/video.mp4", + "durationSeconds": 3 + }, + "expected": { + "error": "unknown-discriminator", + "discriminator": "kind", + "value": "video" + } + }, + { + "name": "content_part_case_collision_is_rejected", + "operation": "load-error", + "input": { + "kind": "Text", + "value": "case-sensitive" + }, + "expected": { + "error": "unknown-discriminator", + "discriminator": "kind", + "value": "Text" + } + } + ] +} diff --git a/spec/vectors/model/named_collection_vectors.json b/spec/vectors/model/named_collection_vectors.json new file mode 100644 index 000000000..9c69f9804 --- /dev/null +++ b/spec/vectors/model/named_collection_vectors.json @@ -0,0 +1,428 @@ +{ + "version": "1", + "description": "Cross-runtime named-collection load/save/reload contracts. Canonical serialization uses a name-keyed object only when every parsed name is non-empty and unique; otherwise it uses a whole-collection array fallback without omission, collision, or synthetic names. Immediate primitive Property values infer kind and default without leaking direct-coercion example semantics. Array-valued entries in name-keyed object form are rejected recursively, while arrays in declared entry fields remain valid.", + "vectors": [ + { + "name": "unique_names_use_canonical_object_form", + "operation": "load-save-reload", + "collectionPath": "inputs", + "input": { + "name": "unique-names", + "inputs": [ + { + "name": "alpha", + "kind": "string", + "description": "first entry" + }, + { + "name": "beta", + "kind": "boolean", + "required": true + } + ] + }, + "expected": { + "collectionFormat": "object", + "entries": [ + { + "name": "alpha", + "kind": "string", + "description": "first entry" + }, + { + "name": "beta", + "kind": "boolean", + "required": true + } + ] + } + }, + { + "name": "missing_and_empty_names_use_lossless_array_fallback", + "operation": "load-save-reload", + "collectionPath": "inputs", + "input": { + "name": "unnamed-inputs", + "inputs": [ + { + "name": "head", + "kind": "string", + "description": "preserve the named head" + }, + { + "kind": "integer", + "default": 7 + }, + { + "name": "", + "kind": "boolean", + "example": false + }, + { + "name": "tail", + "kind": "array", + "default": [ + 1, + null, + { + "nested": [ + "x", + 2 + ] + } + ], + "items": { + "kind": "object" + } + } + ] + }, + "expected": { + "collectionFormat": "array", + "preserveOrder": true, + "entries": [ + { + "name": "head", + "kind": "string", + "description": "preserve the named head" + }, + { + "name": "", + "kind": "integer", + "default": 7 + }, + { + "name": "", + "kind": "boolean", + "example": false + }, + { + "name": "tail", + "kind": "array", + "default": [ + 1, + null, + { + "nested": [ + "x", + 2 + ] + } + ], + "items": { + "kind": "object" + } + } + ] + } + }, + { + "name": "duplicate_names_use_lossless_array_fallback", + "operation": "load-save-reload", + "collectionPath": "inputs", + "input": { + "name": "duplicate-inputs", + "inputs": [ + { + "name": "same", + "kind": "string", + "description": "first duplicate" + }, + { + "name": "same", + "kind": "integer", + "default": 2 + } + ] + }, + "expected": { + "collectionFormat": "array", + "preserveOrder": true, + "entries": [ + { + "name": "same", + "kind": "string", + "description": "first duplicate" + }, + { + "name": "same", + "kind": "integer", + "default": 2 + } + ] + } + }, + { + "name": "unnamed_composite_omits_empty_name_stably", + "operation": "load-save-reload", + "collectionPath": "inputs", + "input": { + "name": "unnamed-composite", + "inputs": [ + { + "kind": "object", + "description": "preserve unnamed composite payload", + "properties": { + "nested": { + "kind": "string", + "default": "kept" + } + } + } + ] + }, + "expected": { + "collectionFormat": "array", + "preserveOrder": true, + "wireEntries": [ + { + "index": 0, + "absentFields": [ + "name" + ] + } + ], + "entries": [ + { + "name": "", + "kind": "object", + "description": "preserve unnamed composite payload", + "properties": { + "nested": { + "kind": "string", + "default": "kept" + } + } + } + ] + } + }, + { + "name": "empty_object_key_reloads_as_unnamed_array_entry", + "operation": "load-save-reload", + "collectionPath": "inputs", + "input": { + "name": "empty-object-key", + "inputs": { + "": { + "kind": "string", + "description": "preserve empty key payload" + }, + "tail": { + "kind": "boolean" + } + } + }, + "expected": { + "collectionFormat": "array", + "entries": [ + { + "name": "", + "kind": "string", + "description": "preserve empty key payload" + }, + { + "name": "tail", + "kind": "boolean" + } + ] + } + }, + { + "name": "array_in_declared_property_field_remains_valid", + "operation": "load-save-reload", + "collectionPath": "inputs", + "input": { + "name": "declared-array-field", + "inputs": { + "aliases": { + "kind": "array", + "default": [ + "Ada", + "Grace", + null + ], + "items": { + "kind": "string" + } + } + } + }, + "expected": { + "collectionFormat": "object", + "entries": [ + { + "name": "aliases", + "kind": "array", + "default": [ + "Ada", + "Grace", + null + ], + "items": { + "kind": "string" + } + } + ] + } + }, + { + "name": "string_scalar_in_name_keyed_inputs_infers_property", + "operation": "load-save-reload", + "collectionPath": "inputs", + "input": { + "name": "string-scalar-input", + "inputs": { + "city": "Seattle" + } + }, + "expected": { + "collectionFormat": "object", + "absentEntryFields": [ + "example" + ], + "entries": [ + { + "name": "city", + "kind": "string", + "default": "Seattle" + } + ] + } + }, + { + "name": "integer_scalar_in_name_keyed_inputs_infers_property", + "operation": "load-save-reload", + "collectionPath": "inputs", + "input": { + "name": "integer-scalar-input", + "inputs": { + "city": 3 + } + }, + "expected": { + "collectionFormat": "object", + "absentEntryFields": [ + "example" + ], + "entries": [ + { + "name": "city", + "kind": "integer", + "default": 3 + } + ] + } + }, + { + "name": "float_scalar_in_name_keyed_inputs_infers_property", + "operation": "load-save-reload", + "collectionPath": "inputs", + "input": { + "name": "float-scalar-input", + "inputs": { + "city": 1.5 + } + }, + "expected": { + "collectionFormat": "object", + "absentEntryFields": [ + "example" + ], + "entries": [ + { + "name": "city", + "kind": "float", + "default": 1.5 + } + ] + } + }, + { + "name": "boolean_scalar_in_name_keyed_inputs_infers_property", + "operation": "load-save-reload", + "collectionPath": "inputs", + "input": { + "name": "boolean-scalar-input", + "inputs": { + "city": true + } + }, + "expected": { + "collectionFormat": "object", + "absentEntryFields": [ + "example" + ], + "entries": [ + { + "name": "city", + "kind": "boolean", + "default": true + } + ] + } + }, + { + "name": "scalar_array_shorthand_in_name_keyed_inputs_is_rejected", + "operation": "load-error", + "input": { + "name": "invalid-scalar-array-shorthand", + "inputs": { + "arrayDefault": [ + 1, + "two", + null + ] + } + }, + "expected": { + "error": "invalid-named-collection-entry", + "path": "inputs.arrayDefault", + "valueCategory": "array" + } + }, + { + "name": "array_value_in_name_keyed_inputs_is_rejected", + "operation": "load-error", + "input": { + "name": "invalid-top-level-array", + "inputs": { + "arrayEntry": [ + { + "kind": "string" + } + ] + } + }, + "expected": { + "error": "invalid-named-collection-entry", + "path": "inputs.arrayEntry", + "valueCategory": "array" + } + }, + { + "name": "array_value_in_nested_properties_is_rejected", + "operation": "load-error", + "input": { + "name": "invalid-recursive-array", + "inputs": { + "profile": { + "kind": "object", + "properties": { + "arrayEntry": [ + { + "kind": "string" + } + ] + } + } + } + }, + "expected": { + "error": "invalid-named-collection-entry", + "path": "inputs.profile.properties.arrayEntry", + "valueCategory": "array" + } + } + ] +} diff --git a/spec/vectors/model/property_scalar_coercion_vectors.json b/spec/vectors/model/property_scalar_coercion_vectors.json new file mode 100644 index 000000000..90b622d0b --- /dev/null +++ b/spec/vectors/model/property_scalar_coercion_vectors.json @@ -0,0 +1,44 @@ +{ + "version": "1", + "description": "Atomic cross-runtime Property scalar coercion contract. Direct generated-model JSON loading infers the exact primitive kind and stores the unmodified scalar in example. All four cases are required together.", + "vectors": [ + { + "name": "all_primitive_property_scalars_coerce_atomically", + "operation": "load", + "cases": [ + { + "name": "string", + "input": "example", + "expected": { + "kind": "string", + "example": "example" + } + }, + { + "name": "integer", + "input": 4, + "expected": { + "kind": "integer", + "example": 4 + } + }, + { + "name": "float", + "input": 3.14, + "expected": { + "kind": "float", + "example": 3.14 + } + }, + { + "name": "boolean", + "input": false, + "expected": { + "kind": "boolean", + "example": false + } + } + ] + } + ] +} diff --git a/spec/vectors/model/record_unknown_nullability_vectors.json b/spec/vectors/model/record_unknown_nullability_vectors.json new file mode 100644 index 000000000..32f9bec1f --- /dev/null +++ b/spec/vectors/model/record_unknown_nullability_vectors.json @@ -0,0 +1,364 @@ +{ + "version": "1", + "description": "Cross-runtime Record nullability contracts. Optionality controls whether the record itself may be absent; present records permit explicit null values at every nesting depth. Load/save/reload must preserve null-valued keys and must not conflate present-null with absence.", + "vectors": [ + { + "name": "message_metadata_preserves_null_values", + "operation": "load-save-reload", + "model": "Message", + "fieldPath": "metadata", + "input": { + "role": "user", + "parts": [], + "metadata": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + "expected": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + { + "name": "prompty_metadata_preserves_null_values", + "operation": "load-save-reload", + "model": "Prompty", + "fieldPath": "metadata", + "input": { + "name": "nullable-prompty-metadata", + "metadata": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + "expected": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + { + "name": "model_info_additional_properties_preserve_null_values", + "operation": "load-save-reload", + "model": "ModelInfo", + "fieldPath": "additionalProperties", + "input": { + "id": "nullable-provider-model", + "additionalProperties": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + "expected": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + { + "name": "turn_model_request_inputs_preserve_null_values", + "operation": "load-save-reload", + "model": "TurnModelRequest", + "fieldPath": "inputs", + "input": { + "sessionId": "sess_nullable", + "turnId": "turn_nullable", + "iteration": 0, + "inputs": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + "expected": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + { + "name": "run_turn_request_inputs_preserve_null_values", + "operation": "load-save-reload", + "model": "RunTurnRequest", + "fieldPath": "inputs", + "input": { + "sessionId": "sess_nullable", + "turnId": "turn_nullable", + "inputs": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + "expected": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + { + "name": "turn_model_response_checkpoint_state_preserves_null_values", + "operation": "load-save-reload", + "model": "TurnModelResponse", + "fieldPath": "checkpointState", + "input": { + "checkpointState": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + "expected": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + { + "name": "host_tool_request_arguments_preserve_null_values", + "operation": "load-save-reload", + "model": "HostToolRequest", + "fieldPath": "arguments", + "input": { + "toolName": "nullable-tool", + "arguments": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + "expected": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + { + "name": "turn_event_payload_preserves_null_values", + "operation": "load-save-reload", + "model": "TurnEvent", + "fieldPath": "payload", + "input": { + "id": "evt_turn_nullable", + "type": "turn_start", + "timestamp": "2026-07-01T00:00:00Z", + "payload": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + "expected": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + { + "name": "session_event_payload_preserves_null_values", + "operation": "load-save-reload", + "model": "SessionEvent", + "fieldPath": "payload", + "input": { + "id": "evt_session_nullable", + "type": "session_start", + "timestamp": "2026-07-01T00:00:00Z", + "payload": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + }, + "expected": { + "direct": null, + "nested": { + "value": null + }, + "list": [ + "text", + null, + { + "deep": [ + null + ] + } + ] + } + } + ] +} diff --git a/spec/vectors/wire/wire_vectors.json b/spec/vectors/wire/wire_vectors.json index 8e28dd475..54d9084e8 100644 --- a/spec/vectors/wire/wire_vectors.json +++ b/spec/vectors/wire/wire_vectors.json @@ -986,6 +986,43 @@ } } }, + { + "name": "anthropic_unmapped_options", + "description": "§7.5 — Anthropic: ModelOptions fields that declare no anthropic wire mapping (frequencyPenalty, presencePenalty, seed) MUST be omitted entirely, NOT emitted under their schema field names.", + "input": { + "provider": "anthropic", + "apiType": "chat", + "model_id": "claude-sonnet-4-20250514", + "messages": [ + { + "role": "user", + "content": [{ "kind": "text", "value": "Hi" }] + } + ], + "tools": [], + "options": { + "temperature": 0.5, + "maxOutputTokens": 2000, + "frequencyPenalty": 0.1, + "presencePenalty": 0.2, + "seed": 42 + }, + "outputs": [] + }, + "expected": { + "request_body": { + "model": "claude-sonnet-4-20250514", + "messages": [ + { + "role": "user", + "content": [{ "type": "text", "text": "Hi" }] + } + ], + "temperature": 0.5, + "max_tokens": 2000 + } + } + }, { "name": "responses_structured_output", "description": "Responses API: structured output uses text.format.json_schema", @@ -1030,5 +1067,44 @@ } } } + }, + { + "name": "responses_unmapped_options", + "description": "§7.5 — Responses API: ModelOptions fields that declare no `responses` wire mapping (frequencyPenalty, presencePenalty, seed, topK, stopSequences, allowMultipleToolCalls) MUST be omitted entirely, NOT emitted under their schema field names. Only maxOutputTokens, temperature and topP map for this surface.", + "input": { + "provider": "openai", + "apiType": "responses", + "model_id": "gpt-4o", + "messages": [ + { + "role": "user", + "content": [{ "kind": "text", "value": "Hi" }] + } + ], + "tools": [], + "options": { + "temperature": 0.5, + "maxOutputTokens": 2000, + "topP": 0.9, + "frequencyPenalty": 0.1, + "presencePenalty": 0.2, + "seed": 42, + "topK": 5, + "stopSequences": ["END"], + "allowMultipleToolCalls": true + }, + "outputs": [] + }, + "expected": { + "request_body": { + "model": "gpt-4o", + "input": [ + { "role": "user", "content": "Hi" } + ], + "temperature": 0.5, + "max_output_tokens": 2000, + "top_p": 0.9 + } + } } ] diff --git a/vscode/prompty/schemas/ArrayProperty.yaml b/vscode/prompty/schemas/ArrayProperty.yaml index 691f4a332..d21262617 100644 --- a/vscode/prompty/schemas/ArrayProperty.yaml +++ b/vscode/prompty/schemas/ArrayProperty.yaml @@ -68,7 +68,6 @@ properties: description: The type of items contained in the array required: - kind - - items allOf: - $ref: Property.yaml description: |- diff --git a/vscode/prompty/schemas/Connection.yaml b/vscode/prompty/schemas/Connection.yaml index bad2cf672..dc6a43b4c 100644 --- a/vscode/prompty/schemas/Connection.yaml +++ b/vscode/prompty/schemas/Connection.yaml @@ -3,19 +3,7 @@ $id: Connection.yaml type: object properties: kind: - anyOf: - - type: string - const: remote - - type: string - const: reference - - type: string - const: key - - type: string - const: anonymous - - type: string - const: foundry - - type: string - const: oauth + type: string description: The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) authenticationMode: anyOf: diff --git a/vscode/prompty/schemas/EngineDurabilityPort.yaml b/vscode/prompty/schemas/EngineDurabilityPort.yaml new file mode 100644 index 000000000..42c25cdc1 --- /dev/null +++ b/vscode/prompty/schemas/EngineDurabilityPort.yaml @@ -0,0 +1,5 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: EngineDurabilityPort.yaml +type: object +properties: {} +description: Persists semantic engine events and checkpoints without runtime cancellation. diff --git a/vscode/prompty/schemas/EnginePermissionPort.yaml b/vscode/prompty/schemas/EnginePermissionPort.yaml new file mode 100644 index 000000000..7bcae1709 --- /dev/null +++ b/vscode/prompty/schemas/EnginePermissionPort.yaml @@ -0,0 +1,5 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: EnginePermissionPort.yaml +type: object +properties: {} +description: Authorizes model-requested tools at a runtime cancellation boundary. diff --git a/vscode/prompty/schemas/EnginePostCommitPort.yaml b/vscode/prompty/schemas/EnginePostCommitPort.yaml new file mode 100644 index 000000000..70ab4d573 --- /dev/null +++ b/vscode/prompty/schemas/EnginePostCommitPort.yaml @@ -0,0 +1,5 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: EnginePostCommitPort.yaml +type: object +properties: {} +description: Runs non-fatal host effects after a turn is durably committed. diff --git a/vscode/prompty/schemas/EngineToolPort.yaml b/vscode/prompty/schemas/EngineToolPort.yaml new file mode 100644 index 000000000..997fce918 --- /dev/null +++ b/vscode/prompty/schemas/EngineToolPort.yaml @@ -0,0 +1,5 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: EngineToolPort.yaml +type: object +properties: {} +description: Executes authorized model-requested tools at a runtime cancellation boundary. diff --git a/vscode/prompty/schemas/HostToolRequest.yaml b/vscode/prompty/schemas/HostToolRequest.yaml index 5ddd117dd..1cb78adfd 100644 --- a/vscode/prompty/schemas/HostToolRequest.yaml +++ b/vscode/prompty/schemas/HostToolRequest.yaml @@ -13,7 +13,7 @@ properties: description: Name of the host tool being executed arguments: $ref: RecordUnknown.yaml - description: Tool arguments after host-side sanitization + description: Tool arguments after host-side sanitization. Values may be explicit null. workingDirectory: type: string description: Working directory or execution scope for the tool diff --git a/vscode/prompty/schemas/McpTool.yaml b/vscode/prompty/schemas/McpTool.yaml index b01f8cc99..59ec3a85f 100644 --- a/vscode/prompty/schemas/McpTool.yaml +++ b/vscode/prompty/schemas/McpTool.yaml @@ -27,7 +27,6 @@ required: - kind - connection - serverName - - approvalMode allOf: - $ref: Tool.yaml description: The MCP Server tool. diff --git a/vscode/prompty/schemas/Message.yaml b/vscode/prompty/schemas/Message.yaml index 85a429d0d..e2ca7d98d 100644 --- a/vscode/prompty/schemas/Message.yaml +++ b/vscode/prompty/schemas/Message.yaml @@ -24,7 +24,7 @@ properties: metadata: $ref: RecordUnknown.yaml default: {} - description: Optional metadata associated with the message + description: Optional metadata associated with the message. Values may be explicit null. required: - role - parts diff --git a/vscode/prompty/schemas/ModelInfo.yaml b/vscode/prompty/schemas/ModelInfo.yaml index b2df752fc..c3953a482 100644 --- a/vscode/prompty/schemas/ModelInfo.yaml +++ b/vscode/prompty/schemas/ModelInfo.yaml @@ -28,7 +28,7 @@ properties: description: Output modalities the model can produce (e.g., 'text', 'audio') additionalProperties: $ref: RecordUnknown.yaml - description: Additional provider-specific properties + description: Additional provider-specific properties. Values may be explicit null. required: - id description: |- diff --git a/vscode/prompty/schemas/Prompty.yaml b/vscode/prompty/schemas/Prompty.yaml index ad1f5fd99..913b04784 100644 --- a/vscode/prompty/schemas/Prompty.yaml +++ b/vscode/prompty/schemas/Prompty.yaml @@ -17,7 +17,7 @@ properties: metadata: $ref: RecordUnknown.yaml default: {} - description: Additional metadata including authors, tags, and other arbitrary properties + description: Additional metadata including authors, tags, and other arbitrary properties. Values may be explicit null. inputs: anyOf: - $ref: RecordProperty.yaml @@ -200,7 +200,6 @@ properties: description: Clear directions on what the prompt should do. In .prompty files, this comes from the markdown body. required: - name - - model description: |- A Prompty is a markdown file format for LLM prompts. The frontmatter defines structured metadata including model configuration, input/output schemas, tools, diff --git a/vscode/prompty/schemas/RunTurnRequest.yaml b/vscode/prompty/schemas/RunTurnRequest.yaml index 2de75fcd5..2ca40cd42 100644 --- a/vscode/prompty/schemas/RunTurnRequest.yaml +++ b/vscode/prompty/schemas/RunTurnRequest.yaml @@ -11,7 +11,7 @@ properties: inputs: $ref: RecordUnknown.yaml default: {} - description: Inputs supplied to the deterministic single-turn run + description: Inputs supplied to the deterministic single-turn run. Values may be explicit null. options: $ref: TurnOptions.yaml description: Canonical turn execution options diff --git a/vscode/prompty/schemas/SessionEvent.yaml b/vscode/prompty/schemas/SessionEvent.yaml index c1af6a3c4..155d7fb3f 100644 --- a/vscode/prompty/schemas/SessionEvent.yaml +++ b/vscode/prompty/schemas/SessionEvent.yaml @@ -40,7 +40,7 @@ properties: payload: $ref: RecordUnknown.yaml default: {} - description: Event-specific payload. Use the typed payload model matching 'type'. + description: Event-specific payload. Values may be explicit null. Use the typed payload model matching 'type'. redaction: $ref: RedactionMetadata.yaml description: Redaction state for sensitive payload fields diff --git a/vscode/prompty/schemas/TurnEvent.yaml b/vscode/prompty/schemas/TurnEvent.yaml index 6551bac34..f4f1f5050 100644 --- a/vscode/prompty/schemas/TurnEvent.yaml +++ b/vscode/prompty/schemas/TurnEvent.yaml @@ -76,7 +76,7 @@ properties: payload: $ref: RecordUnknown.yaml default: {} - description: Event-specific payload. Use the typed payload model matching 'type'. + description: Event-specific payload. Values may be explicit null. Use the typed payload model matching 'type'. required: - id - type diff --git a/vscode/prompty/schemas/TurnModelRequest.yaml b/vscode/prompty/schemas/TurnModelRequest.yaml index c35ea4c21..14ee8f83a 100644 --- a/vscode/prompty/schemas/TurnModelRequest.yaml +++ b/vscode/prompty/schemas/TurnModelRequest.yaml @@ -16,7 +16,7 @@ properties: inputs: $ref: RecordUnknown.yaml default: {} - description: Inputs supplied to the deterministic single-turn run + description: Inputs supplied to the deterministic single-turn run. Values may be explicit null. options: $ref: TurnOptions.yaml description: Canonical turn execution options diff --git a/vscode/prompty/schemas/TurnModelResponse.yaml b/vscode/prompty/schemas/TurnModelResponse.yaml index 0e5b8eda2..1a1030b82 100644 --- a/vscode/prompty/schemas/TurnModelResponse.yaml +++ b/vscode/prompty/schemas/TurnModelResponse.yaml @@ -16,5 +16,5 @@ properties: checkpointState: $ref: RecordUnknown.yaml default: {} - description: Additional deterministic state to merge into the iteration checkpoint + description: Additional deterministic state to merge into the iteration checkpoint. Values may be explicit null. description: Response returned by the injected model callback to the reference turn runner. diff --git a/web/docs-examples/lint_docs.py b/web/docs-examples/lint_docs.py index 6551a7fba..9032dbaee 100644 --- a/web/docs-examples/lint_docs.py +++ b/web/docs-examples/lint_docs.py @@ -115,18 +115,31 @@ class Diagnostic: """A single validation issue.""" - def __init__(self, file: str, line: int | None, message: str, *, is_legacy: bool = False) -> None: + def __init__( + self, + file: str, + line: int | None, + message: str, + *, + is_legacy: bool = False, + kind: str | None = None, + ) -> None: self.file = file self.line = line self.message = message self.is_legacy = is_legacy + self.kind = kind def __str__(self) -> str: loc = self.file if self.line is not None: loc += f":{self.line}" - kind = "legacy property" if self.is_legacy else "unknown property" - return f"[FAIL] {loc} -- {kind} {self.message}" + if self.kind is None: + kind = "legacy property" if self.is_legacy else "unknown property" + else: + kind = self.kind + prefix = f"{kind} " if kind else "" + return f"[FAIL] {loc} -- {prefix}{self.message}" # --------------------------------------------------------------------------- @@ -352,18 +365,34 @@ def _validate_tool(tool: dict, file: str, line: int | None, diagnostics: list[Di if "connection" in tool: _validate_connection(tool["connection"], file, line, diagnostics) - # Validate parameters (PropertySchema — same as inputs) + # Validate parameters — a `Properties` named collection + # (`Record | Named[]`, schema/model/core/properties.tsp). + # Only two forms are canonical: a list of entries, or a name-keyed object. + # A `properties:` wrapper is NOT a third form — it parses as name-keyed + # object form with a single entry named `properties` whose value is an + # array, which the normative contract (spec/spec.md) requires be rejected. if "parameters" in tool: params = tool["parameters"] if isinstance(params, list): for item in params: _validate_input_output_item(item, "tool.parameters", file, line, diagnostics) elif isinstance(params, dict): - if "properties" in params: - props = params["properties"] - if isinstance(props, list): - for item in props: - _validate_input_output_item(item, "tool.parameters", file, line, diagnostics) + for name, value in params.items(): + if isinstance(value, list): + diagnostics.append( + Diagnostic( + file, + line, + f"tool.parameters: invalid named collection entry category array for " + f"'{name}'. `parameters` is a named collection: use the list form " + f"(`parameters:` followed by `- name: ...` entries) or the name-keyed " + f"object form (`parameters:` followed by `{name}:` mapping to an " + f"object). A `properties:` wrapper is not a valid form.", + kind="", + ) + ) + elif isinstance(value, dict): + _validate_input_output_item(value, "tool.parameters", file, line, diagnostics) def _validate_template(data, file: str, line: int | None, diagnostics: list[Diagnostic]) -> None: diff --git a/web/src/content/docs/core-concepts/conversation-history.mdx b/web/src/content/docs/core-concepts/conversation-history.mdx index 9a5596b54..e9ccf99f4 100644 --- a/web/src/content/docs/core-concepts/conversation-history.mdx +++ b/web/src/content/docs/core-concepts/conversation-history.mdx @@ -410,10 +410,9 @@ tools: kind: function description: Get current weather for a city parameters: - properties: - - name: city - kind: string - required: true + - name: city + kind: string + required: true --- system: You are a helpful assistant with access to weather data. diff --git a/web/src/content/docs/reference/EngineDurabilityPort.md b/web/src/content/docs/reference/EngineDurabilityPort.md new file mode 100644 index 000000000..7f364c447 --- /dev/null +++ b/web/src/content/docs/reference/EngineDurabilityPort.md @@ -0,0 +1,36 @@ +--- +title: "EngineDurabilityPort" +description: "Documentation for the EngineDurabilityPort type." +slug: "reference/enginedurabilityport" +--- + + +Persists semantic engine events and checkpoints without runtime cancellation. + +## Class Diagram + +```mermaid +--- +title: EngineDurabilityPort +config: + look: handDrawn + theme: colorful + class: + hideEmptyMembersBox: true +--- +classDiagram + class EngineDurabilityPort { + <> + +append(event: EngineEvent) void [async-capable] + +appendWithCheckpoint(events: EngineEvent[], checkpoint: EngineCheckpoint) void [async-capable, atomic] + } +``` + +## Helper Methods + +The following helper methods are declared via `@method` and must be implemented by every runtime. The schema declares the logical protocol contract; each runtime maps async-capable methods to idiomatic sync/async shapes for that language. + +| Name | Signature | Runtime shape | Description | +| ---- | --------- | ------------- | ----------- | +| `append` | `append(event: EngineEvent) -> void` | async-capable | Append one semantic engine event durably | +| `appendWithCheckpoint` | `appendWithCheckpoint(events: EngineEvent[], checkpoint: EngineCheckpoint) -> void` | async-capable, atomic | Atomically append semantic engine events and persist the checkpoint that reflects them | diff --git a/web/src/content/docs/reference/EnginePermissionPort.md b/web/src/content/docs/reference/EnginePermissionPort.md new file mode 100644 index 000000000..768b8ef28 --- /dev/null +++ b/web/src/content/docs/reference/EnginePermissionPort.md @@ -0,0 +1,34 @@ +--- +title: "EnginePermissionPort" +description: "Documentation for the EnginePermissionPort type." +slug: "reference/enginepermissionport" +--- + + +Authorizes model-requested tools at a runtime cancellation boundary. + +## Class Diagram + +```mermaid +--- +title: EnginePermissionPort +config: + look: handDrawn + theme: colorful + class: + hideEmptyMembersBox: true +--- +classDiagram + class EnginePermissionPort { + <> + +authorize(request: ModelToolRequest) EnginePermissionDecision [async-capable, runtime-cancellable] + } +``` + +## Helper Methods + +The following helper methods are declared via `@method` and must be implemented by every runtime. The schema declares the logical protocol contract; each runtime maps async-capable methods to idiomatic sync/async shapes for that language. + +| Name | Signature | Runtime shape | Description | +| ---- | --------- | ------------- | ----------- | +| `authorize` | `authorize(request: ModelToolRequest) -> EnginePermissionDecision` | async-capable, runtime-cancellable | Authorize one model-requested tool before execution | diff --git a/web/src/content/docs/reference/EnginePostCommitPort.md b/web/src/content/docs/reference/EnginePostCommitPort.md new file mode 100644 index 000000000..40facd964 --- /dev/null +++ b/web/src/content/docs/reference/EnginePostCommitPort.md @@ -0,0 +1,34 @@ +--- +title: "EnginePostCommitPort" +description: "Documentation for the EnginePostCommitPort type." +slug: "reference/enginepostcommitport" +--- + + +Runs non-fatal host effects after a turn is durably committed. + +## Class Diagram + +```mermaid +--- +title: EnginePostCommitPort +config: + look: handDrawn + theme: colorful + class: + hideEmptyMembersBox: true +--- +classDiagram + class EnginePostCommitPort { + <> + +afterCommit(effectId: string, commit: TurnCommit) void [async-capable, runtime-cancellable, non-fatal] + } +``` + +## Helper Methods + +The following helper methods are declared via `@method` and must be implemented by every runtime. The schema declares the logical protocol contract; each runtime maps async-capable methods to idiomatic sync/async shapes for that language. + +| Name | Signature | Runtime shape | Description | +| ---- | --------- | ------------- | ----------- | +| `afterCommit` | `afterCommit(effectId: string, commit: TurnCommit) -> void` | async-capable, runtime-cancellable, non-fatal | Run one idempotent host effect after the turn is durably committed | diff --git a/web/src/content/docs/reference/EngineToolPort.md b/web/src/content/docs/reference/EngineToolPort.md new file mode 100644 index 000000000..14ee2fa3b --- /dev/null +++ b/web/src/content/docs/reference/EngineToolPort.md @@ -0,0 +1,34 @@ +--- +title: "EngineToolPort" +description: "Documentation for the EngineToolPort type." +slug: "reference/enginetoolport" +--- + + +Executes authorized model-requested tools at a runtime cancellation boundary. + +## Class Diagram + +```mermaid +--- +title: EngineToolPort +config: + look: handDrawn + theme: colorful + class: + hideEmptyMembersBox: true +--- +classDiagram + class EngineToolPort { + <> + +execute(request: ModelToolRequest) ModelToolResult [async-capable, runtime-cancellable] + } +``` + +## Helper Methods + +The following helper methods are declared via `@method` and must be implemented by every runtime. The schema declares the logical protocol contract; each runtime maps async-capable methods to idiomatic sync/async shapes for that language. + +| Name | Signature | Runtime shape | Description | +| ---- | --------- | ------------- | ----------- | +| `execute` | `execute(request: ModelToolRequest) -> ModelToolResult` | async-capable, runtime-cancellable | Execute one authorized model-requested tool | diff --git a/web/src/content/docs/reference/Executor.md b/web/src/content/docs/reference/Executor.md index 6431058ef..f1a49f7d1 100644 --- a/web/src/content/docs/reference/Executor.md +++ b/web/src/content/docs/reference/Executor.md @@ -21,8 +21,8 @@ config: classDiagram class Executor { <> - +execute(agent: Prompty, messages: Message[]) unknown [async-capable] - +executeStream(agent: Prompty, messages: Message[]) unknown [async-capable] + +execute(agent: Prompty, messages: Message[]) unknown [async-capable, runtime-cancellable] + +executeStream(agent: Prompty, messages: Message[]) unknown [async-capable, runtime-cancellable, optional default] +formatToolMessages(rawResponse: unknown, toolCalls: ToolCall[], toolResults: string[], textContent: string?) Message[] [sync] } ``` @@ -33,6 +33,6 @@ The following helper methods are declared via `@method` and must be implemented | Name | Signature | Runtime shape | Description | | ---- | --------- | ------------- | ----------- | -| `execute` | `execute(agent: Prompty, messages: Message[]) -> unknown` | async-capable | Call an LLM provider with messages and return the raw response | -| `executeStream` | `executeStream(agent: Prompty, messages: Message[]) -> unknown` | async-capable _(optional default)_ | Call an LLM provider and return a streaming response. Returns a language-specific async iterable/stream of raw chunks. Not all providers support streaming; the default implementation should signal lack of support. | +| `execute` | `execute(agent: Prompty, messages: Message[]) -> unknown` | async-capable, runtime-cancellable | Call an LLM provider with messages and return the raw response | +| `executeStream` | `executeStream(agent: Prompty, messages: Message[]) -> unknown` | async-capable, runtime-cancellable, optional default | Call an LLM provider and return a streaming response. Returns a language-specific async iterable/stream of raw chunks. Not all providers support streaming; the default implementation should signal lack of support. | | `formatToolMessages` | `formatToolMessages(rawResponse: unknown, toolCalls: ToolCall[], toolResults: string[], textContent: string?) -> Message[]` | sync | Format tool call results into messages for the next iteration | diff --git a/web/src/content/docs/reference/HostToolRequest.md b/web/src/content/docs/reference/HostToolRequest.md index 42507dc07..4d945cd89 100644 --- a/web/src/content/docs/reference/HostToolRequest.md +++ b/web/src/content/docs/reference/HostToolRequest.md @@ -44,5 +44,5 @@ workingDirectory: /workspace/project | requestId | string | Stable host execution request identifier | | toolCallId | string | Associated model tool call identifier, when available | | toolName | string | Name of the host tool being executed | -| arguments | dictionary | Tool arguments after host-side sanitization | +| arguments | dictionary | Tool arguments after host-side sanitization. Values may be explicit null. | | workingDirectory | string | Working directory or execution scope for the tool | diff --git a/web/src/content/docs/reference/Message.md b/web/src/content/docs/reference/Message.md index 517c51b29..d07dd2d68 100644 --- a/web/src/content/docs/reference/Message.md +++ b/web/src/content/docs/reference/Message.md @@ -51,7 +51,7 @@ metadata: | ---- | ---- | ----------- | | role | string | The role of the message sender | | parts | [ContentPart[]](../contentpart/) | The content parts of the message(Related Types: [TextPart](../textpart/), [ImagePart](../imagepart/), [FilePart](../filepart/), [AudioPart](../audiopart/)) | -| metadata | dictionary | Optional metadata associated with the message | +| metadata | dictionary | Optional metadata associated with the message. Values may be explicit null. | ## Helper Methods diff --git a/web/src/content/docs/reference/ModelInfo.md b/web/src/content/docs/reference/ModelInfo.md index 835de3d8b..5e2c80428 100644 --- a/web/src/content/docs/reference/ModelInfo.md +++ b/web/src/content/docs/reference/ModelInfo.md @@ -61,4 +61,4 @@ additionalProperties: | contextWindow | int32 | Maximum context window size in tokens | | inputModalities | string[] | Input modalities the model accepts (e.g., 'text', 'image', 'audio') | | outputModalities | string[] | Output modalities the model can produce (e.g., 'text', 'audio') | -| additionalProperties | dictionary | Additional provider-specific properties | +| additionalProperties | dictionary | Additional provider-specific properties. Values may be explicit null. | diff --git a/web/src/content/docs/reference/Parser.md b/web/src/content/docs/reference/Parser.md index 19a1ff249..e3abb1bc6 100644 --- a/web/src/content/docs/reference/Parser.md +++ b/web/src/content/docs/reference/Parser.md @@ -21,7 +21,7 @@ config: classDiagram class Parser { <> - +preRender(template: string) unknown? [sync] + +preRender(template: string) unknown? [sync, optional default] +parse(agent: Prompty, rendered: string, context: Record?) Message[] [async-capable] } ``` @@ -32,5 +32,5 @@ The following helper methods are declared via `@method` and must be implemented | Name | Signature | Runtime shape | Description | | ---- | --------- | ------------- | ----------- | -| `preRender` | `preRender(template: string) -> unknown?` | sync _(optional default)_ | Pre-process a template before rendering, returning modified template and context | +| `preRender` | `preRender(template: string) -> unknown?` | sync, optional default | Pre-process a template before rendering, returning modified template and context | | `parse` | `parse(agent: Prompty, rendered: string, context: Record?) -> Message[]` | async-capable | Parse rendered text into a structured message array | diff --git a/web/src/content/docs/reference/Processor.md b/web/src/content/docs/reference/Processor.md index f72707565..35e25f705 100644 --- a/web/src/content/docs/reference/Processor.md +++ b/web/src/content/docs/reference/Processor.md @@ -22,7 +22,7 @@ classDiagram class Processor { <> +process(agent: Prompty, response: unknown) unknown [async-capable] - +processStream(stream: unknown) unknown [async-capable] + +processStream(stream: unknown) unknown [async-capable, optional default] } ``` @@ -33,4 +33,4 @@ The following helper methods are declared via `@method` and must be implemented | Name | Signature | Runtime shape | Description | | ---- | --------- | ------------- | ----------- | | `process` | `process(agent: Prompty, response: unknown) -> unknown` | async-capable | Extract a clean result from a raw LLM response | -| `processStream` | `processStream(stream: unknown) -> unknown` | async-capable _(optional default)_ | Process a streaming response into a stream of StreamChunk items. Takes raw chunks from the executor and yields processed text, thinking, tool, or error chunks. Not all providers support streaming; the default implementation should signal lack of support. | +| `processStream` | `processStream(stream: unknown) -> unknown` | async-capable, optional default | Process a streaming response into a stream of StreamChunk items. Takes raw chunks from the executor and yields processed text, thinking, tool, or error chunks. Not all providers support streaming; the default implementation should signal lack of support. | diff --git a/web/src/content/docs/reference/Prompty.md b/web/src/content/docs/reference/Prompty.md index b23cde4f6..16c881c37 100644 --- a/web/src/content/docs/reference/Prompty.md +++ b/web/src/content/docs/reference/Prompty.md @@ -206,7 +206,7 @@ instructions: |- | name | string | Human-readable name of the prompt | | displayName | string | Display name for UI purposes | | description | string | Description of the prompt's purpose | -| metadata | dictionary | Additional metadata including authors, tags, and other arbitrary properties | +| metadata | dictionary | Additional metadata including authors, tags, and other arbitrary properties. Values may be explicit null. | | inputs | [Property[]](../property/) | Input parameters that participate in template rendering(Related Types: [ArrayProperty](../arrayproperty/), [ObjectProperty](../objectproperty/), [UnionProperty](../unionproperty/)) | | outputs | [Property[]](../property/) | Expected output format and structure | | model | [Model](../model/) | AI model configuration | diff --git a/web/src/content/docs/reference/RunTurnRequest.md b/web/src/content/docs/reference/RunTurnRequest.md index 6e73d5e5b..88b4f1607 100644 --- a/web/src/content/docs/reference/RunTurnRequest.md +++ b/web/src/content/docs/reference/RunTurnRequest.md @@ -50,7 +50,7 @@ turnId: turn_abc123 | ---- | ---- | ----------- | | sessionId | string | Stable harness session identifier | | turnId | string | Stable turn identifier within the session | -| inputs | dictionary | Inputs supplied to the deterministic single-turn run | +| inputs | dictionary | Inputs supplied to the deterministic single-turn run. Values may be explicit null. | | options | [TurnOptions](../turnoptions/) | Canonical turn execution options | ## Composed Types diff --git a/web/src/content/docs/reference/SessionEvent.md b/web/src/content/docs/reference/SessionEvent.md index a72bbd394..6efcfcb9d 100644 --- a/web/src/content/docs/reference/SessionEvent.md +++ b/web/src/content/docs/reference/SessionEvent.md @@ -60,7 +60,7 @@ spanId: span_hook_001 | turnId | string | Associated turn identifier, when this session event is linked to a turn | | parentId | string | Parent event or span identifier for reconstructing event hierarchy | | spanId | string | Trace span identifier associated with this event | -| payload | dictionary | Event-specific payload. Use the typed payload model matching 'type'. | +| payload | dictionary | Event-specific payload. Values may be explicit null. Use the typed payload model matching 'type'. | | redaction | [RedactionMetadata](../redactionmetadata/) | Redaction state for sensitive payload fields | ## Composed Types diff --git a/web/src/content/docs/reference/TurnEvent.md b/web/src/content/docs/reference/TurnEvent.md index e08a8a8da..5fbe3a10f 100644 --- a/web/src/content/docs/reference/TurnEvent.md +++ b/web/src/content/docs/reference/TurnEvent.md @@ -55,4 +55,4 @@ spanId: span_tool_001 | iteration | int32 | Zero-based agent-loop iteration associated with the event | | parentId | string | Parent event or span identifier for reconstructing event hierarchy | | spanId | string | Trace span identifier associated with this event | -| payload | dictionary | Event-specific payload. Use the typed payload model matching 'type'. | +| payload | dictionary | Event-specific payload. Values may be explicit null. Use the typed payload model matching 'type'. | diff --git a/web/src/content/docs/reference/TurnModelRequest.md b/web/src/content/docs/reference/TurnModelRequest.md index e5bd37a72..c8b9c1c41 100644 --- a/web/src/content/docs/reference/TurnModelRequest.md +++ b/web/src/content/docs/reference/TurnModelRequest.md @@ -69,7 +69,7 @@ iteration: 0 | sessionId | string | Stable harness session identifier | | turnId | string | Stable turn identifier within the session | | iteration | int32 | Zero-based model loop iteration | -| inputs | dictionary | Inputs supplied to the deterministic single-turn run | +| inputs | dictionary | Inputs supplied to the deterministic single-turn run. Values may be explicit null. | | options | [TurnOptions](../turnoptions/) | Canonical turn execution options | | toolResults | [HostToolResult[]](../hosttoolresult/) | Host tool results produced by the previous iteration | diff --git a/web/src/content/docs/reference/TurnModelResponse.md b/web/src/content/docs/reference/TurnModelResponse.md index a47e8295a..2560f6122 100644 --- a/web/src/content/docs/reference/TurnModelResponse.md +++ b/web/src/content/docs/reference/TurnModelResponse.md @@ -48,7 +48,7 @@ classDiagram | output | unknown | Provider-neutral final model output for the turn when no more tools are requested | | usage | [InvocationUsage](../invocationusage/) | Complete cumulative token usage reported for this model invocation, when available | | toolRequests | [HostToolRequest[]](../hosttoolrequest/) | Host tool execution requests emitted by the model callback | -| checkpointState | dictionary | Additional deterministic state to merge into the iteration checkpoint | +| checkpointState | dictionary | Additional deterministic state to merge into the iteration checkpoint. Values may be explicit null. | ## Composed Types