diff --git a/.github/workflows/prompty-swift-check.yml b/.github/workflows/prompty-swift-check.yml new file mode 100644 index 000000000..2d3f95729 --- /dev/null +++ b/.github/workflows/prompty-swift-check.yml @@ -0,0 +1,66 @@ +name: prompty Swift build and test + +on: + pull_request: + paths: + - 'runtime/swift/**' + # The Swift tests are validated against the shared cross-runtime vectors, + # so a spec change must re-run them. + - 'spec/**' + - '.github/workflows/prompty-swift-check.yml' + + workflow_call: + +jobs: + swift-tests: + name: test Swift on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + permissions: + contents: read + defaults: + run: + working-directory: runtime/swift/prompty + steps: + - uses: actions/checkout@v5 + + # macOS runners ship Swift with Xcode; only Linux needs a toolchain. + - name: Install Swift toolchain + if: runner.os == 'Linux' + uses: swift-actions/setup-swift@v2 + with: + swift-version: '6.0' + + # Stamped into the cache key below so a runner-image or toolchain change + # can never restore build artifacts produced by a different compiler. + - name: Show Swift version + run: swift --version | tee "$GITHUB_WORKSPACE/.swift-toolchain-stamp" + + # defaults.run.working-directory does not apply to `uses:` steps, so this + # path is repo-root relative. Package.resolved is untracked, so the key + # covers the manifests and the toolchain instead. + - name: Cache SwiftPM build + uses: actions/cache@v5 + with: + path: runtime/swift/prompty/.build + key: ${{ runner.os }}-swiftpm-${{ hashFiles('.swift-toolchain-stamp', 'runtime/swift/**/Package.swift') }} + + # Builds PromptyModel transitively via the ../prompty-model path + # dependency, so generated model code is compiled here too. + - name: Build with tests + run: swift build --build-tests + + # LiveOpenAITests skip themselves when OPENAI_API_KEY is unset, so this + # stays hermetic on CI while still running end to end locally. + - name: Run tests + run: swift test + + # Only the hand-written runtime is linted. The generated PromptyModel + # sources are the emitter's output, not ours to reformat -- they happen + # to pass today, but a formatting change upstream must not break CI. + - name: Check formatting + if: runner.os == 'Linux' + run: swift format lint --strict --recursive Sources Tests diff --git a/.gitignore b/.gitignore index 7463ed573..e36b9466b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,7 @@ __pycache__/ .pytest_cache/ # Schema emitter build artifacts -schema/emitter/dist/ -schema/tsp-output/* +schema/emitter/dist/schema/tsp-output/* !schema/tsp-output/.typra-generated/ !schema/tsp-output/.typra-generated/export-surfaces.json !schema/tsp-output/.typra-generated/hydration-seams.json @@ -24,3 +23,14 @@ schema/tsp-output/.typra-generated/report.json !schema/tsp-output/json-ast/ !schema/tsp-output/json-ast/model.json .playwright-mcp/ + +# SwiftPM build artifacts and resolved dependency pins. +# Package.resolved is untracked because the Swift runtime is a library: pinning +# transitive versions here would constrain every consumer. +.build/ +.swiftpm/ +Package.resolved + +# Written by the Swift CI workflow so the compiler identity can be folded into +# the SwiftPM cache key; never produced by a normal local build. +.swift-toolchain-stamp diff --git a/runtime/swift/README.md b/runtime/swift/README.md new file mode 100644 index 000000000..045a17aa9 --- /dev/null +++ b/runtime/swift/README.md @@ -0,0 +1,222 @@ +# Prompty for Swift + +A Swift implementation of the Prompty runtime: load a `.prompty` file, render it, +turn it into messages, call a model, and read the result back. + +The Rust runtime is the behavioral reference for this port, and both are checked +against the same cross-runtime vectors in [`spec/vectors`](../../spec/vectors). + +## Layout + +Two SwiftPM packages live here, and the split is deliberate. + +| Package | Module | Contents | Hand-written? | +| --------------- | -------------- | ------------------------------------------- | ------------------ | +| `prompty-model` | `PromptyModel` | The domain types and pipeline protocols | No — generated | +| `prompty` | `Prompty` | Loader, renderers, parser, registry, harness | Yes | +| `prompty` | `PromptyOpenAI`| The OpenAI executor and processor | Yes | + +`prompty-model` is emitted from the TypeSpec definitions in [`schema`](../../schema) +by the Typra emitter. **Never edit anything under `prompty-model/Sources` by hand.** +Every file there is overwritten by the next generation run. If a generated type is +wrong, the fix belongs in the schema or in the emitter — see +[Generated model](#generated-model) below. + +The runtime does not define its own domain types. `Prompty`, `Model`, `Message`, +`ContentPart`, `Tool` and the four pipeline protocols (`Renderer`, `Parser`, +`Executor`, `Processor`) all come from `PromptyModel`, and the hand-written code +conforms to them. + +## Using it + +```swift +import Prompty +import PromptyOpenAI + +Registry.shared.registerDefaults() // jinja2 + mustache renderers, prompty parser +registerOpenAI() // openai executor + processor + +let answer = try await Pipeline.invoke( + path: "basic.prompty", + inputs: ["question": "What is the capital of Iceland?"] +) +``` + +`Pipeline.invoke` is the whole flow. The individual stages are available when you +need to step into the middle of it: + +```swift +let agent = try Loader.load(path: "basic.prompty") +let messages = try await Pipeline.prepare(agent, inputs: inputs) // render + parse +let raw = try await Pipeline.run(agent, messages: messages) // execute + process +``` + +### Tool calls + +When a prompt declares tools, the host drives the loop. Read the calls, then ask +for the arguments the tool should actually receive — that second step is where +tool bindings are applied: + +```swift +let raw = try await Pipeline.run(agent, messages: messages) + +for call in Pipeline.toolCalls(in: raw) { + let args = Pipeline.boundArguments(agent, call: call, inputs: inputs) + let result = try myTools[call.name]!(args) + results.append(result) +} +``` + +The recorded `call` is left as the provider sent it. That matters: a bound value +is hidden from the model on purpose, and `Pipeline.toolMessages` replays the +call's own `arguments` on the next round, so writing the value back into the +call would hand the model exactly what the binding withheld. + +A parameter listed under a tool's `bindings` is deliberately hidden from the +model, and the runtime supplies it from the prompt's own inputs instead: + +```yaml +tools: + - name: get_weather + kind: function + bindings: + unit: + input: preferred_unit # the model never sees `unit`; this fills it in +``` + +`Pipeline.toolCalls(in:)` always returns the model's arguments untouched, so +`Pipeline.boundArguments(_:call:inputs:)` at the dispatch site is what makes a +binding take effect. Skipping it leaves the bound parameter missing entirely — +it was already stripped from the schema, so the model never supplied it. + +Bindings are applied only when the provider's payload is a JSON object (or is +empty, which is the no-argument call). An array, a scalar, or malformed JSON is +passed through rather than replaced by an object holding only the bound values. + +Streaming, structured output and tool calls are covered in +[`Tests/PromptyTests/LiveOpenAITests.swift`](prompty/Tests/PromptyTests/LiveOpenAITests.swift), +which exercises each of them against the real API. + +## Building and testing + +Requires a Swift 6.x toolchain. + +```bash +cd runtime/swift/prompty +swift build +swift test +``` + +### On Windows + +SwiftPM shells out to `git`, and a bare repository in the parent tree makes those +calls fail. Set the escape hatch before building: + +```powershell +$env:GIT_CONFIG_COUNT='1' +$env:GIT_CONFIG_KEY_0='safe.bareRepository' +$env:GIT_CONFIG_VALUE_0='all' +``` + +Incremental builds suppress warnings that a clean build reports. When you care +about the warning output, clean first: + +```powershell +swift package clean +swift build --build-tests +``` + +### Live tests + +Most tests are offline. The tests in `LiveOpenAITests` call the real OpenAI API +and **skip themselves** when `OPENAI_API_KEY` is missing, so a checkout without +credentials still runs a full green suite. + +To run them, put a `.env` beside `Package.swift`: + +``` +OPENAI_API_KEY=sk-... +OPENAI_MODEL=gpt-4o-mini +``` + +That file is ignored by git and must never be committed. The runtime itself never +reads `.env` — populating the environment is the host's job, so the loading lives +in the test, not the library. + +## Generated model + +Regenerate after changing anything in [`schema`](../../schema): + +```bash +cd schema +npm install +npm run generate +``` + +Generation also touches the other runtimes. Keep a Swift change reviewable by +reverting the rest: + +```bash +git checkout -- runtime/rust runtime/python runtime/typescript runtime/go runtime/csharp vscode +``` + +### The emitter shim + +`schema/scripts/patch-swift-emitter-defects.mjs` runs as part of generation and +repairs output that the Swift emitter gets wrong today — most importantly, base +fields dropped from types that `extend` another type, which affects three +`Property` subtypes and all five `Tool` subtypes. + +The shim is a scripted post-generation step, so the generated files are still +never hand-edited. It is pinned to the emitter version it was written against and +**fails loudly** rather than silently mis-patching when it sees a version it does +not recognise, when an anchor it expects is missing, or when it finds a file in a +half-patched state. + +Each defect has been reported upstream. When a release fixes one, delete the +corresponding patch and re-run generation: the shim is meant to shrink to nothing +and then be removed. + +`GeneratedModelRoundTripTests` covers every field the shim injects, so a silently +regressed patch fails the suite rather than the runtime. + +## Conformance + +`Tests/PromptyTests` runs the shared vectors from [`spec/vectors`](../../spec/vectors) +— loading, rendering, parsing, provider wire format, response processing, and +harness replay — plus Swift-specific regression tests for defects the vectors +cannot express, such as Windows line endings. + +### Coverage against the shared vectors + +This port is **not parity-complete**. Six of the ten shared vector files are +exercised, and two of those six run only their OpenAI subset. The other four +describe surface area this runtime does not implement. That is a deliberate +scoping decision for an initial port, not an oversight. + +| Vector file | Cases | Status | +| --------------------------------------- | ------: | ------------------------------------- | +| `load/load_vectors.json` | 25 | Run | +| `render/render_vectors.json` | 23 | Run | +| `parse/parse_vectors.json` | 15 | Run | +| `wire/wire_vectors.json` | 22 / 27 | Run — 5 Anthropic cases skipped | +| `process/process_vectors.json` | 17 / 21 | Run — 4 Anthropic cases skipped | +| `harness/replay_vectors.json` | 5 | Run | +| `engine/turn_vectors.json` | 5 | **Not wired** — engine incomplete | +| `agent/agent_vectors.json` | 28 | **Not implemented** — no agent layer | +| `discovery/discovery_vectors.json` | 7 | **Not implemented** — no discovery | +| `discovery/enrichment_vectors.json` | 9 | **Not implemented** — no enrichment | + +The nine skipped Anthropic cases are provider coverage, not a contract gap: this +package ships the OpenAI provider only, so `WireVectorTests` and +`ProcessVectorTests` filter on `input.provider`. An Anthropic package would pick +them up unchanged. + +The turn engine is the substantive gap. `ReferenceTurnRunner` already implements +the iteration loop, permission mediation, host tool execution, and checkpointing, +so three of the five engine vectors (`final_output`, `ordered_tool_round`, +`permission_denial_is_model_visible`) describe behavior that exists but is not +yet asserted against the shared file. The remaining two — `delegated_provider_state` +and `cancel_before_context` — need delegated provider state and cancellation, +which this port does not provide. Wiring the engine vectors and closing those two +capabilities is follow-up work tracked separately from this PR. diff --git a/runtime/swift/prompty-model/Package.swift b/runtime/swift/prompty-model/Package.swift new file mode 100644 index 000000000..c9dbb1501 --- /dev/null +++ b/runtime/swift/prompty-model/Package.swift @@ -0,0 +1,16 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "PromptyModel", + platforms: [.macOS(.v12), .iOS(.v15)], + products: [.library(name: "PromptyModel", targets: ["PromptyModel"])], + dependencies: [ + .package(url: "https://github.com/jpsim/Yams.git", from: "5.1.3") + ], + targets: [ + .target( + name: "PromptyModel", dependencies: [.product(name: "Yams", package: "Yams")], + path: "Sources/PromptyModel") + ] +) diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/TypraRuntime.swift b/runtime/swift/prompty-model/Sources/PromptyModel/TypraRuntime.swift new file mode 100644 index 000000000..683ff3573 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/TypraRuntime.swift @@ -0,0 +1,171 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation +import Yams + +public struct LoadContext { + public init() {} +} + +public struct SaveContext { + public init() {} +} + +public enum TypraRuntimeError: Error, CustomStringConvertible { + case invalidObject(type: String) + case invalidField(field: String, expected: String) + case invalidEnum(type: String, value: String) + case unknownDiscriminator(type: String, field: String, value: String) + case unsupported(String) + + public var description: String { + switch self { + case .invalidObject(let type): return "Expected object for \(type)." + case .invalidField(let field, let expected): return "Expected \(expected) for field \(field)." + case .invalidEnum(let type, let value): return "Invalid \(type) value: \(value)." + case .unknownDiscriminator(let type, let field, let value): + return "Unknown \(type) discriminator \(field)=\(value)." + case .unsupported(let message): return message + } + } +} + +public protocol TypraModel { + static func load(_ data: Any, context: LoadContext) throws -> Self + func save(_ context: SaveContext) throws -> [String: Any] +} + +public enum TypraRuntime { + public static func object(_ data: Any, typeName: String) throws -> [String: Any] { + guard let object = data as? [String: Any] else { + throw TypraRuntimeError.invalidObject(type: typeName) + } + return object + } + + public static func dictionary(_ data: Any, field: String) throws -> [String: Any] { + guard let object = data as? [String: Any] else { + throw TypraRuntimeError.invalidField(field: field, expected: "dictionary") + } + return object + } + + public static func array(_ data: Any, field: String) throws -> [Any] { + guard let array = data as? [Any] else { + throw TypraRuntimeError.invalidField(field: field, expected: "array") + } + return array + } + + public static func string(_ data: Any, field: String) throws -> String { + guard let value = data as? String else { + throw TypraRuntimeError.invalidField(field: field, expected: "string") + } + return value + } + + public static func bool(_ data: Any, field: String) throws -> Bool { + if let value = data as? Bool { return value } + if let number = data as? NSNumber, isBoolNumber(number) { return number.boolValue } + throw TypraRuntimeError.invalidField(field: field, expected: "boolean") + } + + public static func double(_ data: Any, field: String) throws -> Double { + if let value = data as? Double { return value } + if let value = data as? Float { return Double(value) } + if let value = data as? Int { return Double(value) } + if let value = data as? Int64 { return Double(value) } + if let number = data as? NSNumber { return number.doubleValue } + throw TypraRuntimeError.invalidField(field: field, expected: "number") + } + + public static func float(_ data: Any, field: String) throws -> Float { + return Float(try double(data, field: field)) + } + + public static func int(_ data: Any, field: String) throws -> Int { + let value = try int64(data, field: field) + guard value >= Int64(Int.min) && value <= Int64(Int.max) else { + throw TypraRuntimeError.invalidField(field: field, expected: "integer") + } + return Int(value) + } + + public static func int32(_ data: Any, field: String) throws -> Int32 { + let value = try int64(data, field: field) + guard value >= Int64(Int32.min) && value <= Int64(Int32.max) else { + throw TypraRuntimeError.invalidField(field: field, expected: "int32") + } + return Int32(value) + } + + public static func int64(_ data: Any, field: String) throws -> Int64 { + if let value = data as? NSNumber, !isBoolNumber(value) { + return try exactInt64(value, field: field) + } + if let value = data as? Int64 { return value } + if let value = data as? Int { return Int64(value) } + if let value = data as? Int32 { return Int64(value) } + throw TypraRuntimeError.invalidField(field: field, expected: "integer") + } + + private static func exactInt64(_ number: NSNumber, field: String) throws -> Int64 { + switch String(cString: number.objCType) { + case "s", "i", "l", "q": + return number.int64Value + case "C", "S", "I", "L", "Q": + let value = number.uint64Value + guard value <= UInt64(Int64.max) else { + throw TypraRuntimeError.invalidField(field: field, expected: "int64") + } + return Int64(value) + default: + break + } + var decimal = number.decimalValue + var minimum = Decimal(string: "-9223372036854775808")! + var maximum = Decimal(string: "9223372036854775807")! + guard + NSDecimalCompare(&decimal, &minimum) != .orderedAscending + && NSDecimalCompare(&decimal, &maximum) != .orderedDescending + else { throw TypraRuntimeError.invalidField(field: field, expected: "int64") } + var rounded = Decimal() + NSDecimalRound(&rounded, &decimal, 0, .plain) + guard NSDecimalCompare(&rounded, &decimal) == .orderedSame else { + throw TypraRuntimeError.invalidField(field: field, expected: "integer") + } + guard let parsed = Int64(NSDecimalNumber(decimal: rounded).stringValue) else { + throw TypraRuntimeError.invalidField(field: field, expected: "int64") + } + return parsed + } + + private static func isBoolNumber(_ number: NSNumber) -> Bool { + let type = String(cString: number.objCType) + return type == "c" || type == "B" + } + + public static func jsonObject(from json: String, typeName: String) throws -> Any { + guard let data = json.data(using: .utf8) else { + throw TypraRuntimeError.invalidObject(type: typeName) + } + return try JSONSerialization.jsonObject(with: data, options: []) + } + + public static func jsonString(from value: Any) throws -> String { + let data = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) + return String(data: data, encoding: .utf8) ?? "{}" + } + + public static func yamlObject(from yaml: String, typeName: String) throws -> Any { + guard let value = try Yams.load(yaml: yaml) else { + throw TypraRuntimeError.invalidObject(type: typeName) + } + return value + } + + public static func yamlString(from value: Any) throws -> String { + return try Yams.dump(object: value) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/agent/guardrail_result.swift b/runtime/swift/prompty-model/Sources/PromptyModel/agent/guardrail_result.swift new file mode 100644 index 000000000..02ef92b45 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/agent/guardrail_result.swift @@ -0,0 +1,80 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// The result of a guardrail evaluation. Guardrails are safety checks that run at specific phases of the agent loop and can allow, deny, or rewrite content. +public struct GuardrailResult: TypraModel { + public var allowed: Bool = false + public var reason: String? = nil + public var rewrite: Any? = nil + + public init(allowed: Bool = false, reason: String? = nil, rewrite: Any? = nil) { + self.allowed = allowed + self.reason = reason + self.rewrite = rewrite + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> GuardrailResult + { + let object = try TypraRuntime.object(data, typeName: "GuardrailResult") + var instance = GuardrailResult() + if let value = object["allowed"] { + instance.allowed = try TypraRuntime.bool(value, field: "allowed") + } + if let value = object["reason"] { + instance.reason = try TypraRuntime.string(value, field: "reason") + } + if let value = object["rewrite"] { + instance.rewrite = value + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["allowed"] = self.allowed + if let value = self.reason { + result["reason"] = value + } + if let value = self.rewrite { + result["rewrite"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> GuardrailResult + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "GuardrailResult"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> GuardrailResult + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "GuardrailResult"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } + + public static func rewrite(rewrite: Any) -> GuardrailResult { + return GuardrailResult(allowed: true, rewrite: rewrite) + } + + public static func deny(reason: String) -> GuardrailResult { + return GuardrailResult(allowed: false, reason: reason) + } + + public static func allow() -> GuardrailResult { + return GuardrailResult(allowed: true) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/agent/prompty.swift b/runtime/swift/prompty-model/Sources/PromptyModel/agent/prompty.swift new file mode 100644 index 000000000..ec3f9528d --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/agent/prompty.swift @@ -0,0 +1,131 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A Prompty is a markdown file format for LLM prompts. The frontmatter defines structured metadata including model configuration, input/output schemas, tools, and template settings. The markdown body becomes the instructions. This is the single root type for the Prompty schema — there is no abstract base class or kind discriminator. A .prompty file always produces a Prompty instance. Runtime loaders may resolve frontmatter references such as `${env:VAR}` and `${file:relative/path}`. File references must be treated as a host-controlled capability: by default they are scoped to the containing .prompty file's directory tree after canonicalization, and any additional allowed roots must be supplied by the host application's load options rather than frontmatter. +public struct Prompty: TypraModel { + public var name: String = "" + public var displayName: String? = nil + public var description: String? = nil + public var metadata: [String: Any]? = nil + public var inputs: [Property]? = nil + public var outputs: [Property]? = nil + public var model: Model = Model() + public var tools: [Tool]? = nil + public var template: Template? = nil + public var instructions: String? = nil + + public init( + name: String = "", displayName: String? = nil, description: String? = nil, + metadata: [String: Any]? = nil, inputs: [Property]? = nil, outputs: [Property]? = nil, + model: Model = Model(), tools: [Tool]? = nil, template: Template? = nil, + instructions: String? = nil + ) { + self.name = name + self.displayName = displayName + self.description = description + self.metadata = metadata + self.inputs = inputs + self.outputs = outputs + self.model = model + self.tools = tools + self.template = template + self.instructions = instructions + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> Prompty { + let object = try TypraRuntime.object(data, typeName: "Prompty") + var instance = Prompty() + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } else { + instance.name = "" + } + if let value = object["displayName"] { + instance.displayName = try TypraRuntime.string(value, field: "displayName") + } + if let value = object["description"] { + instance.description = try TypraRuntime.string(value, field: "description") + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + if let value = object["inputs"] { + instance.inputs = try TypraRuntime.array(value, field: "inputs").map { + try Property.load($0, context: context) + } + } + if let value = object["outputs"] { + instance.outputs = try TypraRuntime.array(value, field: "outputs").map { + try Property.load($0, context: context) + } + } + if let value = object["model"] { + instance.model = try Model.load(value, context: context) + } + if let value = object["tools"] { + instance.tools = try TypraRuntime.array(value, field: "tools").map { + try Tool.load($0, context: context) + } + } + if let value = object["template"] { + instance.template = try Template.load(value, context: context) + } + if let value = object["instructions"] { + instance.instructions = try TypraRuntime.string(value, field: "instructions") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["name"] = self.name + if let value = self.displayName { + result["displayName"] = value + } + if let value = self.description { + result["description"] = value + } + if let value = self.metadata { + result["metadata"] = value + } + if let value = self.inputs { + result["inputs"] = try value.map { try $0.save(context) } + } + if let value = self.outputs { + result["outputs"] = try value.map { try $0.save(context) } + } + result["model"] = try self.model.save(context) + if let value = self.tools { + result["tools"] = try value.map { try $0.save(context) } + } + if let value = self.template { + result["template"] = try value.save(context) + } + if let value = self.instructions { + result["instructions"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> Prompty + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "Prompty"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> Prompty + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "Prompty"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/connection/authorization_code_flow.swift b/runtime/swift/prompty-model/Sources/PromptyModel/connection/authorization_code_flow.swift new file mode 100644 index 000000000..c7d139e38 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/connection/authorization_code_flow.swift @@ -0,0 +1,77 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Provider-neutral initialization result for an authorization-code flow. +public struct AuthorizationCodeFlow: TypraModel { + public var authUrl: String = "" + public var codeVerifier: String = "" + + public init(authUrl: String = "", codeVerifier: String = "") { + self.authUrl = authUrl + self.codeVerifier = codeVerifier + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> AuthorizationCodeFlow + { + let object = try TypraRuntime.object(data, typeName: "AuthorizationCodeFlow") + var instance = AuthorizationCodeFlow() + if let value = object["authUrl"] { + instance.authUrl = try TypraRuntime.string(value, field: "authUrl") + } + if let value = object["codeVerifier"] { + instance.codeVerifier = try TypraRuntime.string(value, field: "codeVerifier") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["authUrl"] = self.authUrl + result["codeVerifier"] = self.codeVerifier + return result + } + + public func toWire(_ provider: String, context: SaveContext = SaveContext()) throws -> [String: + Any] + { + var result: [String: Any] = [:] + let wireNameAuthUrl: String + switch provider { + case "foundry": wireNameAuthUrl = "auth_url" + default: wireNameAuthUrl = "authUrl" + } + result[wireNameAuthUrl] = self.authUrl + let wireNameCodeVerifier: String + switch provider { + case "foundry": wireNameCodeVerifier = "code_verifier" + default: wireNameCodeVerifier = "codeVerifier" + } + result[wireNameCodeVerifier] = self.codeVerifier + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> AuthorizationCodeFlow + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "AuthorizationCodeFlow"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> AuthorizationCodeFlow + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "AuthorizationCodeFlow"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/connection/connection.swift b/runtime/swift/prompty-model/Sources/PromptyModel/connection/connection.swift new file mode 100644 index 000000000..548f20ba9 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/connection/connection.swift @@ -0,0 +1,490 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum AuthenticationMode: String, Codable, CaseIterable { + case user = "user" + case system = "system" + public static func parse(_ value: String) throws -> AuthenticationMode { + switch value { + case "user": return .user + case "system": return .system + default: throw TypraRuntimeError.invalidEnum(type: "AuthenticationMode", value: value) + } + } +} + +public enum Connection: TypraModel { + case referenceConnection(ReferenceConnection) + case remoteConnection(RemoteConnection) + case apiKeyConnection(ApiKeyConnection) + case anonymousConnection(AnonymousConnection) + case oAuthConnection(OAuthConnection) + case foundryConnection(FoundryConnection) + case unknown([String: Any]) + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> Connection { + let object = try TypraRuntime.object(data, typeName: "Connection") + let discriminator = try TypraRuntime.string(object["kind"] ?? "", field: "kind") + switch discriminator { + case "reference": + return .referenceConnection(try ReferenceConnection.load(data, context: context)) + case "remote": return .remoteConnection(try RemoteConnection.load(data, context: context)) + case "key": return .apiKeyConnection(try ApiKeyConnection.load(data, context: context)) + case "anonymous": + return .anonymousConnection(try AnonymousConnection.load(data, context: context)) + case "oauth": return .oAuthConnection(try OAuthConnection.load(data, context: context)) + case "foundry": return .foundryConnection(try FoundryConnection.load(data, context: context)) + default: + // Deliberate forward-compatibility override, not an emitter defect. + // `Connection` declares no wildcard subtype in TypeSpec, so closing + // this enum and throwing here is correct emitter output. Preserving + // the raw payload instead extends the spec's unknown-property rule + // (spec.md 2.3) to unknown discriminator values, so that forward- + // compatible files survive a load/save cycle. Note this is stronger + // than the Rust runtime, which does not throw but does rewrite the + // discriminator and drop the payload. Retires only once the schema + // opens the union and regenerated output is measured to preserve + // unknown kinds on its own. + return .unknown(object) + } + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + switch self { + case .referenceConnection(let value): return try value.save(context) + case .remoteConnection(let value): return try value.save(context) + case .apiKeyConnection(let value): return try value.save(context) + case .anonymousConnection(let value): return try value.save(context) + case .oAuthConnection(let value): return try value.save(context) + case .foundryConnection(let value): return try value.save(context) + case .unknown(let value): return value + } + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> Connection + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "Connection"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> Connection + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "Connection"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// Connection configuration for AI services using named connections. +public struct ReferenceConnection: TypraModel { + public var kind: String = "reference" + public var name: String = "" + public var target: String? = nil + + public init(kind: String = "reference", name: String = "", target: String? = nil) { + self.kind = kind + self.name = name + self.target = target + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ReferenceConnection + { + let object = try TypraRuntime.object(data, typeName: "ReferenceConnection") + var instance = ReferenceConnection() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "reference" + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["target"] { + instance.target = try TypraRuntime.string(value, field: "target") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["name"] = self.name + if let value = self.target { + result["target"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ReferenceConnection + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ReferenceConnection"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ReferenceConnection + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ReferenceConnection"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// Connection configuration for AI services using named connections. +public struct RemoteConnection: TypraModel { + public var kind: String = "remote" + public var name: String = "" + public var endpoint: String = "" + + public init(kind: String = "remote", name: String = "", endpoint: String = "") { + self.kind = kind + self.name = name + self.endpoint = endpoint + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> RemoteConnection + { + let object = try TypraRuntime.object(data, typeName: "RemoteConnection") + var instance = RemoteConnection() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "remote" + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["endpoint"] { + instance.endpoint = try TypraRuntime.string(value, field: "endpoint") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["name"] = self.name + result["endpoint"] = self.endpoint + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> RemoteConnection + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "RemoteConnection"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> RemoteConnection + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "RemoteConnection"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// Connection configuration for AI services using API keys. +public struct ApiKeyConnection: TypraModel { + public var kind: String = "key" + public var endpoint: String = "" + public var apiKey: String = "" + + public init(kind: String = "key", endpoint: String = "", apiKey: String = "") { + self.kind = kind + self.endpoint = endpoint + self.apiKey = apiKey + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ApiKeyConnection + { + let object = try TypraRuntime.object(data, typeName: "ApiKeyConnection") + var instance = ApiKeyConnection() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "key" + } + if let value = object["endpoint"] { + instance.endpoint = try TypraRuntime.string(value, field: "endpoint") + } + if let value = object["apiKey"] { + instance.apiKey = try TypraRuntime.string(value, field: "apiKey") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["endpoint"] = self.endpoint + result["apiKey"] = self.apiKey + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ApiKeyConnection + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ApiKeyConnection"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ApiKeyConnection + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ApiKeyConnection"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +public struct AnonymousConnection: TypraModel { + public var kind: String = "anonymous" + public var endpoint: String = "" + + public init(kind: String = "anonymous", endpoint: String = "") { + self.kind = kind + self.endpoint = endpoint + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> AnonymousConnection + { + let object = try TypraRuntime.object(data, typeName: "AnonymousConnection") + var instance = AnonymousConnection() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "anonymous" + } + if let value = object["endpoint"] { + instance.endpoint = try TypraRuntime.string(value, field: "endpoint") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["endpoint"] = self.endpoint + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> AnonymousConnection + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "AnonymousConnection"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> AnonymousConnection + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "AnonymousConnection"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// Connection configuration using OAuth 2.0 client credentials. Useful for tools and services that require OAuth authentication, such as MCP servers, OpenAPI endpoints, or other REST APIs. +public struct OAuthConnection: TypraModel { + public var kind: String = "oauth" + public var endpoint: String = "" + public var clientId: String = "" + public var clientSecret: String = "" + public var tokenUrl: String = "" + public var scopes: [String]? = nil + + public init( + kind: String = "oauth", endpoint: String = "", clientId: String = "", clientSecret: String = "", + tokenUrl: String = "", scopes: [String]? = nil + ) { + self.kind = kind + self.endpoint = endpoint + self.clientId = clientId + self.clientSecret = clientSecret + self.tokenUrl = tokenUrl + self.scopes = scopes + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> OAuthConnection + { + let object = try TypraRuntime.object(data, typeName: "OAuthConnection") + var instance = OAuthConnection() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "oauth" + } + if let value = object["endpoint"] { + instance.endpoint = try TypraRuntime.string(value, field: "endpoint") + } + if let value = object["clientId"] { + instance.clientId = try TypraRuntime.string(value, field: "clientId") + } + if let value = object["clientSecret"] { + instance.clientSecret = try TypraRuntime.string(value, field: "clientSecret") + } + if let value = object["tokenUrl"] { + instance.tokenUrl = try TypraRuntime.string(value, field: "tokenUrl") + } + if let value = object["scopes"] { + instance.scopes = try TypraRuntime.array(value, field: "scopes").map { + try TypraRuntime.string($0, field: "scopes") + } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["endpoint"] = self.endpoint + result["clientId"] = self.clientId + result["clientSecret"] = self.clientSecret + result["tokenUrl"] = self.tokenUrl + if let value = self.scopes { + result["scopes"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> OAuthConnection + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "OAuthConnection"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> OAuthConnection + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "OAuthConnection"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// Connection configuration for Microsoft Foundry projects. Provides project-scoped access to models, tools, and services via Entra ID (DefaultAzureCredential) authentication. +public struct FoundryConnection: TypraModel { + public var kind: String = "foundry" + public var endpoint: String = "" + public var name: String? = nil + public var connectionType: String? = nil + + public init( + kind: String = "foundry", endpoint: String = "", name: String? = nil, + connectionType: String? = nil + ) { + self.kind = kind + self.endpoint = endpoint + self.name = name + self.connectionType = connectionType + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> FoundryConnection + { + let object = try TypraRuntime.object(data, typeName: "FoundryConnection") + var instance = FoundryConnection() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "foundry" + } + if let value = object["endpoint"] { + instance.endpoint = try TypraRuntime.string(value, field: "endpoint") + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["connectionType"] { + instance.connectionType = try TypraRuntime.string(value, field: "connectionType") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["endpoint"] = self.endpoint + if let value = self.name { + result["name"] = value + } + if let value = self.connectionType { + result["connectionType"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> FoundryConnection + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "FoundryConnection"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> FoundryConnection + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "FoundryConnection"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/connection/device_authorization.swift b/runtime/swift/prompty-model/Sources/PromptyModel/connection/device_authorization.swift new file mode 100644 index 000000000..ae3a7e9db --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/connection/device_authorization.swift @@ -0,0 +1,130 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Provider-neutral device authorization challenge shown to a user. +public struct DeviceAuthorization: TypraModel { + public var deviceCode: String = "" + public var userCode: String = "" + public var verificationUri: String = "" + public var expiresIn: Int64 = 0 + public var interval: Int64 = 0 + public var message: String = "" + + public init( + deviceCode: String = "", userCode: String = "", verificationUri: String = "", + expiresIn: Int64 = 0, interval: Int64 = 0, message: String = "" + ) { + self.deviceCode = deviceCode + self.userCode = userCode + self.verificationUri = verificationUri + self.expiresIn = expiresIn + self.interval = interval + self.message = message + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> DeviceAuthorization + { + let object = try TypraRuntime.object(data, typeName: "DeviceAuthorization") + var instance = DeviceAuthorization() + if let value = object["deviceCode"] { + instance.deviceCode = try TypraRuntime.string(value, field: "deviceCode") + } + if let value = object["userCode"] { + instance.userCode = try TypraRuntime.string(value, field: "userCode") + } + if let value = object["verificationUri"] { + instance.verificationUri = try TypraRuntime.string(value, field: "verificationUri") + } + if let value = object["expiresIn"] { + instance.expiresIn = try TypraRuntime.int64(value, field: "expiresIn") + } + if let value = object["interval"] { + instance.interval = try TypraRuntime.int64(value, field: "interval") + } + if let value = object["message"] { + instance.message = try TypraRuntime.string(value, field: "message") + } else { + instance.message = "" + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["deviceCode"] = self.deviceCode + result["userCode"] = self.userCode + result["verificationUri"] = self.verificationUri + result["expiresIn"] = self.expiresIn + result["interval"] = self.interval + result["message"] = self.message + return result + } + + public func toWire(_ provider: String, context: SaveContext = SaveContext()) throws -> [String: + Any] + { + var result: [String: Any] = [:] + let wireNameDeviceCode: String + switch provider { + case "foundry": wireNameDeviceCode = "device_code" + default: wireNameDeviceCode = "deviceCode" + } + result[wireNameDeviceCode] = self.deviceCode + let wireNameUserCode: String + switch provider { + case "foundry": wireNameUserCode = "user_code" + default: wireNameUserCode = "userCode" + } + result[wireNameUserCode] = self.userCode + let wireNameVerificationUri: String + switch provider { + case "foundry": wireNameVerificationUri = "verification_uri" + default: wireNameVerificationUri = "verificationUri" + } + result[wireNameVerificationUri] = self.verificationUri + let wireNameExpiresIn: String + switch provider { + case "foundry": wireNameExpiresIn = "expires_in" + default: wireNameExpiresIn = "expiresIn" + } + result[wireNameExpiresIn] = self.expiresIn + let wireNameInterval: String + switch provider { + case "foundry": wireNameInterval = "interval" + default: wireNameInterval = "interval" + } + result[wireNameInterval] = self.interval + let wireNameMessage: String + switch provider { + case "foundry": wireNameMessage = "message" + default: wireNameMessage = "message" + } + result[wireNameMessage] = self.message + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> DeviceAuthorization + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "DeviceAuthorization"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> DeviceAuthorization + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "DeviceAuthorization"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/connection/o_auth_token.swift b/runtime/swift/prompty-model/Sources/PromptyModel/connection/o_auth_token.swift new file mode 100644 index 000000000..cb42796bc --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/connection/o_auth_token.swift @@ -0,0 +1,116 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Provider-neutral OAuth token material returned by an authorization server. +public struct OAuthToken: TypraModel { + public var accessToken: String = "" + public var tokenType: String = "" + public var expiresIn: Int64 = 0 + public var refreshToken: String? = nil + public var scope: String? = nil + + public init( + accessToken: String = "", tokenType: String = "", expiresIn: Int64 = 0, + refreshToken: String? = nil, scope: String? = nil + ) { + self.accessToken = accessToken + self.tokenType = tokenType + self.expiresIn = expiresIn + self.refreshToken = refreshToken + self.scope = scope + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> OAuthToken { + let object = try TypraRuntime.object(data, typeName: "OAuthToken") + var instance = OAuthToken() + if let value = object["accessToken"] { + instance.accessToken = try TypraRuntime.string(value, field: "accessToken") + } + if let value = object["tokenType"] { + instance.tokenType = try TypraRuntime.string(value, field: "tokenType") + } + if let value = object["expiresIn"] { + instance.expiresIn = try TypraRuntime.int64(value, field: "expiresIn") + } + if let value = object["refreshToken"] { + instance.refreshToken = try TypraRuntime.string(value, field: "refreshToken") + } + if let value = object["scope"] { + instance.scope = try TypraRuntime.string(value, field: "scope") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["accessToken"] = self.accessToken + result["tokenType"] = self.tokenType + result["expiresIn"] = self.expiresIn + if let value = self.refreshToken { + result["refreshToken"] = value + } + if let value = self.scope { + result["scope"] = value + } + return result + } + + public func toWire(_ provider: String, context: SaveContext = SaveContext()) throws -> [String: + Any] + { + var result: [String: Any] = [:] + let wireNameAccessToken: String + switch provider { + case "foundry": wireNameAccessToken = "access_token" + default: wireNameAccessToken = "accessToken" + } + result[wireNameAccessToken] = self.accessToken + let wireNameTokenType: String + switch provider { + case "foundry": wireNameTokenType = "token_type" + default: wireNameTokenType = "tokenType" + } + result[wireNameTokenType] = self.tokenType + let wireNameExpiresIn: String + switch provider { + case "foundry": wireNameExpiresIn = "expires_in" + default: wireNameExpiresIn = "expiresIn" + } + result[wireNameExpiresIn] = self.expiresIn + let wireNameRefreshToken: String + switch provider { + case "foundry": wireNameRefreshToken = "refresh_token" + default: wireNameRefreshToken = "refreshToken" + } + if let value = self.refreshToken { result[wireNameRefreshToken] = value } + let wireNameScope: String + switch provider { + case "foundry": wireNameScope = "scope" + default: wireNameScope = "scope" + } + if let value = self.scope { result[wireNameScope] = value } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> OAuthToken + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "OAuthToken"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> OAuthToken + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "OAuthToken"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/conversation/content_part.swift b/runtime/swift/prompty-model/Sources/PromptyModel/conversation/content_part.swift new file mode 100644 index 000000000..002840f6d --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/conversation/content_part.swift @@ -0,0 +1,296 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum ContentPart: TypraModel { + case textPart(TextPart) + case imagePart(ImagePart) + case filePart(FilePart) + case audioPart(AudioPart) + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> ContentPart { + let object = try TypraRuntime.object(data, typeName: "ContentPart") + let discriminator = try TypraRuntime.string(object["kind"] ?? "", field: "kind") + switch discriminator { + case "text": return .textPart(try TextPart.load(data, context: context)) + case "image": return .imagePart(try ImagePart.load(data, context: context)) + case "file": return .filePart(try FilePart.load(data, context: context)) + case "audio": return .audioPart(try AudioPart.load(data, context: context)) + default: + throw TypraRuntimeError.unknownDiscriminator( + type: "ContentPart", field: "kind", value: discriminator) + } + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + switch self { + case .textPart(let value): return try value.save(context) + case .imagePart(let value): return try value.save(context) + case .filePart(let value): return try value.save(context) + case .audioPart(let value): return try value.save(context) + } + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ContentPart + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "ContentPart"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ContentPart + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "ContentPart"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// A text content part. +public struct TextPart: TypraModel { + public var kind: String = "text" + public var value: String = "" + + public init(kind: String = "text", value: String = "") { + self.kind = kind + self.value = value + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> TextPart { + let object = try TypraRuntime.object(data, typeName: "TextPart") + var instance = TextPart() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "text" + } + if let value = object["value"] { + instance.value = try TypraRuntime.string(value, field: "value") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["value"] = self.value + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TextPart + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "TextPart"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TextPart + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "TextPart"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// An image content part. The source may be a URL or base64-encoded data. +public struct ImagePart: TypraModel { + public var kind: String = "image" + public var source: String = "" + public var detail: String? = nil + public var mediaType: String? = nil + + public init( + kind: String = "image", source: String = "", detail: String? = nil, mediaType: String? = nil + ) { + self.kind = kind + self.source = source + self.detail = detail + self.mediaType = mediaType + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> ImagePart { + let object = try TypraRuntime.object(data, typeName: "ImagePart") + var instance = ImagePart() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "image" + } + if let value = object["source"] { + instance.source = try TypraRuntime.string(value, field: "source") + } + if let value = object["detail"] { + instance.detail = try TypraRuntime.string(value, field: "detail") + } + if let value = object["mediaType"] { + instance.mediaType = try TypraRuntime.string(value, field: "mediaType") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["source"] = self.source + if let value = self.detail { + result["detail"] = value + } + if let value = self.mediaType { + result["mediaType"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ImagePart + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "ImagePart"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ImagePart + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "ImagePart"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// A file content part. The source may be a URL or base64-encoded data. +public struct FilePart: TypraModel { + public var kind: String = "file" + public var source: String = "" + public var mediaType: String? = nil + + public init(kind: String = "file", source: String = "", mediaType: String? = nil) { + self.kind = kind + self.source = source + self.mediaType = mediaType + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> FilePart { + let object = try TypraRuntime.object(data, typeName: "FilePart") + var instance = FilePart() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "file" + } + if let value = object["source"] { + instance.source = try TypraRuntime.string(value, field: "source") + } + if let value = object["mediaType"] { + instance.mediaType = try TypraRuntime.string(value, field: "mediaType") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["source"] = self.source + if let value = self.mediaType { + result["mediaType"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> FilePart + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "FilePart"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> FilePart + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "FilePart"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// An audio content part. The source may be a URL or base64-encoded data. +public struct AudioPart: TypraModel { + public var kind: String = "audio" + public var source: String = "" + public var mediaType: String? = nil + + public init(kind: String = "audio", source: String = "", mediaType: String? = nil) { + self.kind = kind + self.source = source + self.mediaType = mediaType + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> AudioPart { + let object = try TypraRuntime.object(data, typeName: "AudioPart") + var instance = AudioPart() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "audio" + } + if let value = object["source"] { + instance.source = try TypraRuntime.string(value, field: "source") + } + if let value = object["mediaType"] { + instance.mediaType = try TypraRuntime.string(value, field: "mediaType") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["source"] = self.source + if let value = self.mediaType { + result["mediaType"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> AudioPart + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "AudioPart"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> AudioPart + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "AudioPart"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/conversation/message.swift b/runtime/swift/prompty-model/Sources/PromptyModel/conversation/message.swift new file mode 100644 index 000000000..802fee7c2 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/conversation/message.swift @@ -0,0 +1,104 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum Role: String, Codable, CaseIterable { + case system = "system" + case user = "user" + case assistant = "assistant" + case developer = "developer" + case tool = "tool" + public static func parse(_ value: String) throws -> Role { + switch value { + case "system": return .system + case "user": return .user + case "assistant": return .assistant + case "developer": return .developer + case "tool": return .tool + default: throw TypraRuntimeError.invalidEnum(type: "Role", value: value) + } + } +} + +/// A message in a conversation. Messages have a role and a list of content parts representing the different modalities of the message content. +public struct Message: TypraModel { + public var role: Role = (try! Role.parse("user")) + public var parts: [ContentPart] = [] + public var metadata: [String: Any] = [:] + + public init( + role: Role = (try! Role.parse("user")), parts: [ContentPart] = [], metadata: [String: Any] = [:] + ) { + self.role = role + self.parts = parts + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> Message { + let object = try TypraRuntime.object(data, typeName: "Message") + var instance = Message() + if let value = object["role"] { + instance.role = try Role.parse(try TypraRuntime.string(value, field: "role")) + } else { + instance.role = (try! Role.parse("user")) + } + if let value = object["parts"] { + instance.parts = try TypraRuntime.array(value, field: "parts").map { + try ContentPart.load($0, context: context) + } + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["role"] = self.role.rawValue + result["parts"] = try self.parts.map { try $0.save(context) } + result["metadata"] = self.metadata + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> Message + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "Message"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> Message + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "Message"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } + + public static func assistant(text: String) -> Message { + return Message(role: .assistant, parts: [.textPart(TextPart(kind: "text", value: text))]) + } + + public static func system(text: String) -> Message { + return Message(role: .system, parts: [.textPart(TextPart(kind: "text", value: text))]) + } + + public static func user(text: String) -> Message { + return Message(role: .user, parts: [.textPart(TextPart(kind: "text", value: text))]) + } + + public func toTextContent() async throws -> Any { + throw TypraRuntimeError.unsupported("toTextContent must be implemented by hand-authored code.") + } + + public func text() async throws -> String { + throw TypraRuntimeError.unsupported("text must be implemented by hand-authored code.") + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/conversation/thread_marker.swift b/runtime/swift/prompty-model/Sources/PromptyModel/conversation/thread_marker.swift new file mode 100644 index 000000000..5bc11ba4c --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/conversation/thread_marker.swift @@ -0,0 +1,59 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Positional marker for conversation history insertion during template rendering. During `prepare()`, nonce strings in rendered text are replaced with ThreadMarker objects. The pipeline then replaces them with actual conversation messages from the inputs. +public struct ThreadMarker: TypraModel { + public var name: String = "thread" + public var kind: String = "thread" + + public init(name: String = "thread", kind: String = "thread") { + self.name = name + self.kind = kind + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> ThreadMarker + { + let object = try TypraRuntime.object(data, typeName: "ThreadMarker") + var instance = ThreadMarker() + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } else { + instance.name = "thread" + } + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "thread" + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["name"] = self.name + result["kind"] = self.kind + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ThreadMarker + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "ThreadMarker"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ThreadMarker + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "ThreadMarker"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/conversation/tool_call.swift b/runtime/swift/prompty-model/Sources/PromptyModel/conversation/tool_call.swift new file mode 100644 index 000000000..c0003e1e5 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/conversation/tool_call.swift @@ -0,0 +1,60 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A tool call requested by the LLM. Contains the function name and serialized arguments that should be dispatched to the appropriate tool handler. +public struct ToolCall: TypraModel { + public var id: String = "" + public var name: String = "" + public var arguments: String = "" + + public init(id: String = "", name: String = "", arguments: String = "") { + self.id = id + self.name = name + self.arguments = arguments + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> ToolCall { + let object = try TypraRuntime.object(data, typeName: "ToolCall") + var instance = ToolCall() + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["arguments"] { + instance.arguments = try TypraRuntime.string(value, field: "arguments") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["id"] = self.id + result["name"] = self.name + result["arguments"] = self.arguments + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ToolCall + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "ToolCall"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ToolCall + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "ToolCall"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/conversation/tool_result.swift b/runtime/swift/prompty-model/Sources/PromptyModel/conversation/tool_result.swift new file mode 100644 index 000000000..c789c6eca --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/conversation/tool_result.swift @@ -0,0 +1,109 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum ToolResultStatus: String, Codable, CaseIterable { + case success = "success" + case error = "error" + case cancelled = "cancelled" + case timeout = "timeout" + public static func parse(_ value: String) throws -> ToolResultStatus { + switch value { + case "success": return .success + case "error": return .error + case "cancelled": return .cancelled + case "timeout": return .timeout + default: throw TypraRuntimeError.invalidEnum(type: "ToolResultStatus", value: value) + } + } +} + +/// The result of a tool execution. Contains a list of content parts, enabling rich tool results (text, images, files, audio) rather than just strings. Implementations MUST support conversion from a plain string to a ToolResult containing a single TextPart for backward compatibility. +public struct ToolResult: TypraModel { + public var parts: [ContentPart] = [] + public var status: ToolResultStatus? = nil + public var errorKind: String? = nil + public var errorMessage: String? = nil + public var durationMs: Double? = nil + + public init( + parts: [ContentPart] = [], status: ToolResultStatus? = nil, errorKind: String? = nil, + errorMessage: String? = nil, durationMs: Double? = nil + ) { + self.parts = parts + self.status = status + self.errorKind = errorKind + self.errorMessage = errorMessage + self.durationMs = durationMs + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> ToolResult { + let object = try TypraRuntime.object(data, typeName: "ToolResult") + var instance = ToolResult() + if let value = object["parts"] { + instance.parts = try TypraRuntime.array(value, field: "parts").map { + try ContentPart.load($0, context: context) + } + } + if let value = object["status"] { + instance.status = try ToolResultStatus.parse(try TypraRuntime.string(value, field: "status")) + } + if let value = object["errorKind"] { + instance.errorKind = try TypraRuntime.string(value, field: "errorKind") + } + if let value = object["errorMessage"] { + instance.errorMessage = try TypraRuntime.string(value, field: "errorMessage") + } + if let value = object["durationMs"] { + instance.durationMs = try TypraRuntime.double(value, field: "durationMs") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["parts"] = try self.parts.map { try $0.save(context) } + if let value = self.status { + result["status"] = value.rawValue + } + if let value = self.errorKind { + result["errorKind"] = value + } + if let value = self.errorMessage { + result["errorMessage"] = value + } + if let value = self.durationMs { + result["durationMs"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ToolResult + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "ToolResult"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ToolResult + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "ToolResult"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } + + public static func text(value: String) -> ToolResult { + return ToolResult(parts: [.textPart(TextPart(kind: "text", value: value))]) + } + + public func text() async throws -> String { + throw TypraRuntimeError.unsupported("text must be implemented by hand-authored code.") + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/core/file_not_found_error.swift b/runtime/swift/prompty-model/Sources/PromptyModel/core/file_not_found_error.swift new file mode 100644 index 000000000..cca1dea5e --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/core/file_not_found_error.swift @@ -0,0 +1,58 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Raised when a referenced file cannot be found. This applies to both .prompty files and ${file:path} references in frontmatter. +public struct FileNotFoundError: TypraModel { + public var message: String = "" + public var path: String = "" + + public init(message: String = "", path: String = "") { + self.message = message + self.path = path + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> FileNotFoundError + { + let object = try TypraRuntime.object(data, typeName: "FileNotFoundError") + var instance = FileNotFoundError() + if let value = object["message"] { + instance.message = try TypraRuntime.string(value, field: "message") + } + if let value = object["path"] { + instance.path = try TypraRuntime.string(value, field: "path") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["message"] = self.message + result["path"] = self.path + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> FileNotFoundError + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "FileNotFoundError"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> FileNotFoundError + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "FileNotFoundError"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/core/invoker_error.swift b/runtime/swift/prompty-model/Sources/PromptyModel/core/invoker_error.swift new file mode 100644 index 000000000..7969d4463 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/core/invoker_error.swift @@ -0,0 +1,61 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Raised when no invoker implementation is registered for a given component and key. For example, if no renderer is registered for the key "jinja2", an InvokerError is raised. +public struct InvokerError: TypraModel { + public var message: String = "" + public var component: String = "" + public var key: String = "" + + public init(message: String = "", component: String = "", key: String = "") { + self.message = message + self.component = component + self.key = key + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> InvokerError + { + let object = try TypraRuntime.object(data, typeName: "InvokerError") + var instance = InvokerError() + if let value = object["message"] { + instance.message = try TypraRuntime.string(value, field: "message") + } + if let value = object["component"] { + instance.component = try TypraRuntime.string(value, field: "component") + } + if let value = object["key"] { + instance.key = try TypraRuntime.string(value, field: "key") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["message"] = self.message + result["component"] = self.component + result["key"] = self.key + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> InvokerError + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "InvokerError"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> InvokerError + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "InvokerError"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/core/property.swift b/runtime/swift/prompty-model/Sources/PromptyModel/core/property.swift new file mode 100644 index 000000000..e869f6d0d --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/core/property.swift @@ -0,0 +1,338 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public indirect enum Property: TypraModel { + case arrayProperty(ArrayProperty) + case objectProperty(ObjectProperty) + case unionProperty(UnionProperty) + case unknown([String: Any]) + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> Property { + let object = try TypraRuntime.object(data, typeName: "Property") + let discriminator = try TypraRuntime.string(object["kind"] ?? "", field: "kind") + switch discriminator { + case "array": return .arrayProperty(try ArrayProperty.load(data, context: context)) + case "object": return .objectProperty(try ObjectProperty.load(data, context: context)) + case "union": return .unionProperty(try UnionProperty.load(data, context: context)) + default: return .unknown(object) + } + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + switch self { + case .arrayProperty(let value): return try value.save(context) + case .objectProperty(let value): return try value.save(context) + case .unionProperty(let value): return try value.save(context) + case .unknown(let value): return value + } + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> Property + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "Property"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> Property + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "Property"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// Represents an array property. This extends the base Property model to represent an array of items. +public struct ArrayProperty: TypraModel { + public var kind: String = "array" + public var items: Property = .unknown([:]) + public var name: String = "" + public var description: String? = nil + public var required: Bool? = nil + public var nullable: Bool? = nil + public var `default`: Any? = nil + public var example: Any? = nil + public var enumValues: [Any]? = nil + + public init(kind: String = "array", items: Property = .unknown([:])) { + self.kind = kind + self.items = items + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> ArrayProperty + { + let object = try TypraRuntime.object(data, typeName: "ArrayProperty") + var instance = ArrayProperty() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "array" + } + if let value = object["items"] { + instance.items = try Property.load(value, context: context) + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["description"], !(value is NSNull) { + instance.description = try TypraRuntime.string(value, field: "description") + } + if let value = object["required"], !(value is NSNull) { + instance.required = try TypraRuntime.bool(value, field: "required") + } + if let value = object["nullable"], !(value is NSNull) { + instance.nullable = try TypraRuntime.bool(value, field: "nullable") + } + if let value = object["default"], !(value is NSNull) { + instance.default = value + } + if let value = object["example"], !(value is NSNull) { + instance.example = value + } + if let value = object["enumValues"], !(value is NSNull) { + instance.enumValues = try TypraRuntime.array(value, field: "enumValues") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["items"] = try self.items.save(context) + if !self.name.isEmpty { result["name"] = self.name } + if let value = self.description { result["description"] = value } + if let value = self.required { result["required"] = value } + if let value = self.nullable { result["nullable"] = value } + if let value = self.default { result["default"] = value } + if let value = self.example { result["example"] = value } + if let value = self.enumValues { result["enumValues"] = value } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ArrayProperty + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ArrayProperty"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ArrayProperty + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ArrayProperty"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// Represents an object property. This extends the base Property model to represent a structured object. +public struct ObjectProperty: TypraModel { + public var kind: String = "object" + public var properties: [Property] = [] + public var name: String = "" + public var description: String? = nil + public var required: Bool? = nil + public var nullable: Bool? = nil + public var `default`: Any? = nil + public var example: Any? = nil + public var enumValues: [Any]? = nil + + public init(kind: String = "object", properties: [Property] = []) { + self.kind = kind + self.properties = properties + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ObjectProperty + { + let object = try TypraRuntime.object(data, typeName: "ObjectProperty") + var instance = ObjectProperty() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "object" + } + if let value = object["properties"] { + instance.properties = try TypraRuntime.array(value, field: "properties").map { + try Property.load($0, context: context) + } + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["description"], !(value is NSNull) { + instance.description = try TypraRuntime.string(value, field: "description") + } + if let value = object["required"], !(value is NSNull) { + instance.required = try TypraRuntime.bool(value, field: "required") + } + if let value = object["nullable"], !(value is NSNull) { + instance.nullable = try TypraRuntime.bool(value, field: "nullable") + } + if let value = object["default"], !(value is NSNull) { + instance.default = value + } + if let value = object["example"], !(value is NSNull) { + instance.example = value + } + if let value = object["enumValues"], !(value is NSNull) { + instance.enumValues = try TypraRuntime.array(value, field: "enumValues") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["properties"] = try self.properties.map { try $0.save(context) } + if !self.name.isEmpty { result["name"] = self.name } + if let value = self.description { result["description"] = value } + if let value = self.required { result["required"] = value } + if let value = self.nullable { result["nullable"] = value } + if let value = self.default { result["default"] = value } + if let value = self.example { result["example"] = value } + if let value = self.enumValues { result["enumValues"] = value } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ObjectProperty + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ObjectProperty"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ObjectProperty + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ObjectProperty"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// Represents a JSON Schema union property. Use `oneOf` when exactly one branch must match, or `anyOf` when one or more branches may match. Exactly one composition field MUST be provided with at least one branch; `oneOf` and `anyOf` MUST NOT both be populated. The alternatives are full Prompty properties so unions remain portable across generated runtimes. +public struct UnionProperty: TypraModel { + public var kind: String = "union" + public var oneOf: [Property]? = nil + public var anyOf: [Property]? = nil + public var name: String = "" + public var description: String? = nil + public var required: Bool? = nil + public var nullable: Bool? = nil + public var `default`: Any? = nil + public var example: Any? = nil + public var enumValues: [Any]? = nil + + public init(kind: String = "union", oneOf: [Property]? = nil, anyOf: [Property]? = nil) { + self.kind = kind + self.oneOf = oneOf + self.anyOf = anyOf + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> UnionProperty + { + let object = try TypraRuntime.object(data, typeName: "UnionProperty") + var instance = UnionProperty() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "union" + } + if let value = object["oneOf"] { + instance.oneOf = try TypraRuntime.array(value, field: "oneOf").map { + try Property.load($0, context: context) + } + } + if let value = object["anyOf"] { + instance.anyOf = try TypraRuntime.array(value, field: "anyOf").map { + try Property.load($0, context: context) + } + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["description"], !(value is NSNull) { + instance.description = try TypraRuntime.string(value, field: "description") + } + if let value = object["required"], !(value is NSNull) { + instance.required = try TypraRuntime.bool(value, field: "required") + } + if let value = object["nullable"], !(value is NSNull) { + instance.nullable = try TypraRuntime.bool(value, field: "nullable") + } + if let value = object["default"], !(value is NSNull) { + instance.default = value + } + if let value = object["example"], !(value is NSNull) { + instance.example = value + } + if let value = object["enumValues"], !(value is NSNull) { + instance.enumValues = try TypraRuntime.array(value, field: "enumValues") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + if let value = self.oneOf { + result["oneOf"] = try value.map { try $0.save(context) } + } + if let value = self.anyOf { + result["anyOf"] = try value.map { try $0.save(context) } + } + if !self.name.isEmpty { result["name"] = self.name } + if let value = self.description { result["description"] = value } + if let value = self.required { result["required"] = value } + if let value = self.nullable { result["nullable"] = value } + if let value = self.default { result["default"] = value } + if let value = self.example { result["example"] = value } + if let value = self.enumValues { result["enumValues"] = value } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> UnionProperty + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "UnionProperty"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> UnionProperty + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "UnionProperty"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/core/validation_error.swift b/runtime/swift/prompty-model/Sources/PromptyModel/core/validation_error.swift new file mode 100644 index 000000000..d980b58d1 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/core/validation_error.swift @@ -0,0 +1,64 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Raised when input validation fails. Each ValidationError describes a single property that did not satisfy its constraint. +public struct ValidationError: TypraModel { + public var message: String = "" + public var property: String = "" + public var constraint: String = "" + + public init(message: String = "", property: String = "", constraint: String = "") { + self.message = message + self.property = property + self.constraint = constraint + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ValidationError + { + let object = try TypraRuntime.object(data, typeName: "ValidationError") + var instance = ValidationError() + if let value = object["message"] { + instance.message = try TypraRuntime.string(value, field: "message") + } + if let value = object["property"] { + instance.property = try TypraRuntime.string(value, field: "property") + } + if let value = object["constraint"] { + instance.constraint = try TypraRuntime.string(value, field: "constraint") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["message"] = self.message + result["property"] = self.property + result["constraint"] = self.constraint + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ValidationError + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ValidationError"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ValidationError + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ValidationError"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/core/validation_result.swift b/runtime/swift/prompty-model/Sources/PromptyModel/core/validation_result.swift new file mode 100644 index 000000000..62f31d0c0 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/core/validation_result.swift @@ -0,0 +1,60 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// The result of validating inputs against a Prompty's inputs. Returned by `validate_inputs` (§12.2) to indicate whether all required inputs are present and satisfy their constraints. +public struct ValidationResult: TypraModel { + public var valid: Bool = false + public var errors: [ValidationError] = [] + + public init(valid: Bool = false, errors: [ValidationError] = []) { + self.valid = valid + self.errors = errors + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ValidationResult + { + let object = try TypraRuntime.object(data, typeName: "ValidationResult") + var instance = ValidationResult() + if let value = object["valid"] { + instance.valid = try TypraRuntime.bool(value, field: "valid") + } + if let value = object["errors"] { + instance.errors = try TypraRuntime.array(value, field: "errors").map { + try ValidationError.load($0, context: context) + } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["valid"] = self.valid + result["errors"] = try self.errors.map { try $0.save(context) } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ValidationResult + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ValidationResult"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ValidationResult + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ValidationResult"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/checkpoint.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/checkpoint.swift new file mode 100644 index 000000000..8c3b9c2b8 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/checkpoint.swift @@ -0,0 +1,133 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A persisted handoff point for a harness session. +public struct Checkpoint: TypraModel { + public var id: String? = nil + public var sessionId: String? = nil + public var turnId: String? = nil + public var checkpointNumber: Int32? = nil + public var title: String = "" + public var overview: String? = nil + public var state: [String: Any]? = nil + public var summary: String? = nil + public var metadata: [String: Any]? = nil + public var createdAt: String? = nil + public var redaction: RedactionMetadata? = nil + + public init( + id: String? = nil, sessionId: String? = nil, turnId: String? = nil, + checkpointNumber: Int32? = nil, title: String = "", overview: String? = nil, + state: [String: Any]? = nil, summary: String? = nil, metadata: [String: Any]? = nil, + createdAt: String? = nil, redaction: RedactionMetadata? = nil + ) { + self.id = id + self.sessionId = sessionId + self.turnId = turnId + self.checkpointNumber = checkpointNumber + self.title = title + self.overview = overview + self.state = state + self.summary = summary + self.metadata = metadata + self.createdAt = createdAt + self.redaction = redaction + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> Checkpoint { + let object = try TypraRuntime.object(data, typeName: "Checkpoint") + var instance = Checkpoint() + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["checkpointNumber"] { + instance.checkpointNumber = try TypraRuntime.int32(value, field: "checkpointNumber") + } + if let value = object["title"] { + instance.title = try TypraRuntime.string(value, field: "title") + } + if let value = object["overview"] { + instance.overview = try TypraRuntime.string(value, field: "overview") + } + if let value = object["state"] { + instance.state = try TypraRuntime.dictionary(value, field: "state") + } + if let value = object["summary"] { + instance.summary = try TypraRuntime.string(value, field: "summary") + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + if let value = object["createdAt"] { + instance.createdAt = try TypraRuntime.string(value, field: "createdAt") + } + if let value = object["redaction"] { + instance.redaction = try RedactionMetadata.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.id { + result["id"] = value + } + if let value = self.sessionId { + result["sessionId"] = value + } + if let value = self.turnId { + result["turnId"] = value + } + if let value = self.checkpointNumber { + result["checkpointNumber"] = value + } + result["title"] = self.title + if let value = self.overview { + result["overview"] = value + } + if let value = self.state { + result["state"] = value + } + if let value = self.summary { + result["summary"] = value + } + if let value = self.metadata { + result["metadata"] = value + } + if let value = self.createdAt { + result["createdAt"] = value + } + if let value = self.redaction { + result["redaction"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> Checkpoint + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "Checkpoint"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> Checkpoint + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "Checkpoint"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/compaction_complete_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/compaction_complete_payload.swift new file mode 100644 index 000000000..ab9de1501 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/compaction_complete_payload.swift @@ -0,0 +1,66 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "compaction_complete" events — context compaction finished. +public struct CompactionCompletePayload: TypraModel { + public var removed: Int32 = 0 + public var remaining: Int32 = 0 + public var summaryLength: Int32? = nil + + public init(removed: Int32 = 0, remaining: Int32 = 0, summaryLength: Int32? = nil) { + self.removed = removed + self.remaining = remaining + self.summaryLength = summaryLength + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> CompactionCompletePayload + { + let object = try TypraRuntime.object(data, typeName: "CompactionCompletePayload") + var instance = CompactionCompletePayload() + if let value = object["removed"] { + instance.removed = try TypraRuntime.int32(value, field: "removed") + } + if let value = object["remaining"] { + instance.remaining = try TypraRuntime.int32(value, field: "remaining") + } + if let value = object["summaryLength"] { + instance.summaryLength = try TypraRuntime.int32(value, field: "summaryLength") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["removed"] = self.removed + result["remaining"] = self.remaining + if let value = self.summaryLength { + result["summaryLength"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> CompactionCompletePayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "CompactionCompletePayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> CompactionCompletePayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "CompactionCompletePayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/compaction_failed_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/compaction_failed_payload.swift new file mode 100644 index 000000000..7f8041f64 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/compaction_failed_payload.swift @@ -0,0 +1,52 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "compaction_failed" events — compaction could not be completed. +public struct CompactionFailedPayload: TypraModel { + public var message: String = "" + + public init(message: String = "") { + self.message = message + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> CompactionFailedPayload + { + let object = try TypraRuntime.object(data, typeName: "CompactionFailedPayload") + var instance = CompactionFailedPayload() + if let value = object["message"] { + instance.message = try TypraRuntime.string(value, field: "message") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["message"] = self.message + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> CompactionFailedPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "CompactionFailedPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> CompactionFailedPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "CompactionFailedPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/compaction_start_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/compaction_start_payload.swift new file mode 100644 index 000000000..2fcbfd9e2 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/compaction_start_payload.swift @@ -0,0 +1,52 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "compaction_start" events — context compaction is beginning. +public struct CompactionStartPayload: TypraModel { + public var droppedCount: Int32 = 0 + + public init(droppedCount: Int32 = 0) { + self.droppedCount = droppedCount + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> CompactionStartPayload + { + let object = try TypraRuntime.object(data, typeName: "CompactionStartPayload") + var instance = CompactionStartPayload() + if let value = object["droppedCount"] { + instance.droppedCount = try TypraRuntime.int32(value, field: "droppedCount") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["droppedCount"] = self.droppedCount + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> CompactionStartPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "CompactionStartPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> CompactionStartPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "CompactionStartPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/done_event_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/done_event_payload.swift new file mode 100644 index 000000000..170565280 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/done_event_payload.swift @@ -0,0 +1,60 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "done" events — the agent loop completed successfully. +public struct DoneEventPayload: TypraModel { + public var response: Any = NSNull() + public var messages: [Message] = [] + + public init(response: Any = NSNull(), messages: [Message] = []) { + self.response = response + self.messages = messages + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> DoneEventPayload + { + let object = try TypraRuntime.object(data, typeName: "DoneEventPayload") + var instance = DoneEventPayload() + if let value = object["response"] { + instance.response = value + } + if let value = object["messages"] { + instance.messages = try TypraRuntime.array(value, field: "messages").map { + try Message.load($0, context: context) + } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["response"] = self.response + result["messages"] = try self.messages.map { try $0.save(context) } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> DoneEventPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "DoneEventPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> DoneEventPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "DoneEventPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/error_event_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/error_event_payload.swift new file mode 100644 index 000000000..88045234b --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/error_event_payload.swift @@ -0,0 +1,68 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "error" events — an error occurred during the loop. +public struct ErrorEventPayload: TypraModel { + public var message: String = "" + public var errorKind: String? = nil + public var phase: String? = nil + + public init(message: String = "", errorKind: String? = nil, phase: String? = nil) { + self.message = message + self.errorKind = errorKind + self.phase = phase + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ErrorEventPayload + { + let object = try TypraRuntime.object(data, typeName: "ErrorEventPayload") + var instance = ErrorEventPayload() + if let value = object["message"] { + instance.message = try TypraRuntime.string(value, field: "message") + } + if let value = object["errorKind"] { + instance.errorKind = try TypraRuntime.string(value, field: "errorKind") + } + if let value = object["phase"] { + instance.phase = try TypraRuntime.string(value, field: "phase") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["message"] = self.message + if let value = self.errorKind { + result["errorKind"] = value + } + if let value = self.phase { + result["phase"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ErrorEventPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ErrorEventPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ErrorEventPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ErrorEventPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/harness_context.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/harness_context.swift new file mode 100644 index 000000000..14711449d --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/harness_context.swift @@ -0,0 +1,70 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Execution context associated with a harness session. Host-specific environments can store detailed profiles in metadata without making the core contract depend on one source-control provider or workspace shape. +public struct HarnessContext: TypraModel { + public var cwd: String? = nil + public var gitRoot: String? = nil + public var metadata: [String: Any]? = nil + + public init(cwd: String? = nil, gitRoot: String? = nil, metadata: [String: Any]? = nil) { + self.cwd = cwd + self.gitRoot = gitRoot + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> HarnessContext + { + let object = try TypraRuntime.object(data, typeName: "HarnessContext") + var instance = HarnessContext() + if let value = object["cwd"] { + instance.cwd = try TypraRuntime.string(value, field: "cwd") + } + if let value = object["gitRoot"] { + instance.gitRoot = try TypraRuntime.string(value, field: "gitRoot") + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.cwd { + result["cwd"] = value + } + if let value = self.gitRoot { + result["gitRoot"] = value + } + if let value = self.metadata { + result["metadata"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> HarnessContext + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "HarnessContext"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> HarnessContext + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "HarnessContext"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/hook_end_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/hook_end_payload.swift new file mode 100644 index 000000000..912866bf3 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/hook_end_payload.swift @@ -0,0 +1,120 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum HookEndScope: String, Codable, CaseIterable { + case turn = "turn" + case session = "session" + public static func parse(_ value: String) throws -> HookEndScope { + switch value { + case "turn": return .turn + case "session": return .session + default: throw TypraRuntimeError.invalidEnum(type: "HookEndScope", value: value) + } + } +} + +/// Payload for "hook_end" events — a host lifecycle hook finished. +public struct HookEndPayload: TypraModel { + public var hookInvocationId: String = "" + public var hookType: String = "" + public var scope: HookEndScope? = nil + public var success: Bool = false + public var output: [String: Any]? = nil + public var durationMs: Double? = nil + public var error: String? = nil + public var redaction: RedactionMetadata? = nil + + public init( + hookInvocationId: String = "", hookType: String = "", scope: HookEndScope? = nil, + success: Bool = false, output: [String: Any]? = nil, durationMs: Double? = nil, + error: String? = nil, redaction: RedactionMetadata? = nil + ) { + self.hookInvocationId = hookInvocationId + self.hookType = hookType + self.scope = scope + self.success = success + self.output = output + self.durationMs = durationMs + self.error = error + self.redaction = redaction + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> HookEndPayload + { + let object = try TypraRuntime.object(data, typeName: "HookEndPayload") + var instance = HookEndPayload() + if let value = object["hookInvocationId"] { + instance.hookInvocationId = try TypraRuntime.string(value, field: "hookInvocationId") + } + if let value = object["hookType"] { + instance.hookType = try TypraRuntime.string(value, field: "hookType") + } + if let value = object["scope"] { + instance.scope = try HookEndScope.parse(try TypraRuntime.string(value, field: "scope")) + } + if let value = object["success"] { + instance.success = try TypraRuntime.bool(value, field: "success") + } + if let value = object["output"] { + instance.output = try TypraRuntime.dictionary(value, field: "output") + } + if let value = object["durationMs"] { + instance.durationMs = try TypraRuntime.double(value, field: "durationMs") + } + if let value = object["error"] { + instance.error = try TypraRuntime.string(value, field: "error") + } + if let value = object["redaction"] { + instance.redaction = try RedactionMetadata.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["hookInvocationId"] = self.hookInvocationId + result["hookType"] = self.hookType + if let value = self.scope { + result["scope"] = value.rawValue + } + result["success"] = self.success + if let value = self.output { + result["output"] = value + } + if let value = self.durationMs { + result["durationMs"] = value + } + if let value = self.error { + result["error"] = value + } + if let value = self.redaction { + result["redaction"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> HookEndPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "HookEndPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> HookEndPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "HookEndPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/hook_start_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/hook_start_payload.swift new file mode 100644 index 000000000..421e71013 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/hook_start_payload.swift @@ -0,0 +1,97 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum HookStartScope: String, Codable, CaseIterable { + case turn = "turn" + case session = "session" + public static func parse(_ value: String) throws -> HookStartScope { + switch value { + case "turn": return .turn + case "session": return .session + default: throw TypraRuntimeError.invalidEnum(type: "HookStartScope", value: value) + } + } +} + +/// Payload for "hook_start" events — a host lifecycle hook is beginning. +public struct HookStartPayload: TypraModel { + public var hookInvocationId: String = "" + public var hookType: String = "" + public var scope: HookStartScope? = nil + public var input: [String: Any]? = nil + public var redaction: RedactionMetadata? = nil + + public init( + hookInvocationId: String = "", hookType: String = "", scope: HookStartScope? = nil, + input: [String: Any]? = nil, redaction: RedactionMetadata? = nil + ) { + self.hookInvocationId = hookInvocationId + self.hookType = hookType + self.scope = scope + self.input = input + self.redaction = redaction + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> HookStartPayload + { + let object = try TypraRuntime.object(data, typeName: "HookStartPayload") + var instance = HookStartPayload() + if let value = object["hookInvocationId"] { + instance.hookInvocationId = try TypraRuntime.string(value, field: "hookInvocationId") + } + if let value = object["hookType"] { + instance.hookType = try TypraRuntime.string(value, field: "hookType") + } + if let value = object["scope"] { + instance.scope = try HookStartScope.parse(try TypraRuntime.string(value, field: "scope")) + } + if let value = object["input"] { + instance.input = try TypraRuntime.dictionary(value, field: "input") + } + if let value = object["redaction"] { + instance.redaction = try RedactionMetadata.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["hookInvocationId"] = self.hookInvocationId + result["hookType"] = self.hookType + if let value = self.scope { + result["scope"] = value.rawValue + } + if let value = self.input { + result["input"] = value + } + if let value = self.redaction { + result["redaction"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> HookStartPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "HookStartPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> HookStartPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "HookStartPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/host_tool_request.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/host_tool_request.swift new file mode 100644 index 000000000..75f4e46eb --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/host_tool_request.swift @@ -0,0 +1,87 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Request passed to a host tool executor after policy and permission checks. +public struct HostToolRequest: TypraModel { + public var requestId: String? = nil + public var toolCallId: String? = nil + public var toolName: String = "" + public var arguments: [String: Any]? = nil + public var workingDirectory: String? = nil + + public init( + requestId: String? = nil, toolCallId: String? = nil, toolName: String = "", + arguments: [String: Any]? = nil, workingDirectory: String? = nil + ) { + self.requestId = requestId + self.toolCallId = toolCallId + self.toolName = toolName + self.arguments = arguments + self.workingDirectory = workingDirectory + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> HostToolRequest + { + let object = try TypraRuntime.object(data, typeName: "HostToolRequest") + var instance = HostToolRequest() + if let value = object["requestId"] { + instance.requestId = try TypraRuntime.string(value, field: "requestId") + } + if let value = object["toolCallId"] { + instance.toolCallId = try TypraRuntime.string(value, field: "toolCallId") + } + if let value = object["toolName"] { + instance.toolName = try TypraRuntime.string(value, field: "toolName") + } + if let value = object["arguments"] { + instance.arguments = try TypraRuntime.dictionary(value, field: "arguments") + } + if let value = object["workingDirectory"] { + instance.workingDirectory = try TypraRuntime.string(value, field: "workingDirectory") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.requestId { + result["requestId"] = value + } + if let value = self.toolCallId { + result["toolCallId"] = value + } + result["toolName"] = self.toolName + if let value = self.arguments { + result["arguments"] = value + } + if let value = self.workingDirectory { + result["workingDirectory"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> HostToolRequest + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "HostToolRequest"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> HostToolRequest + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "HostToolRequest"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/host_tool_result.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/host_tool_result.swift new file mode 100644 index 000000000..d6671b503 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/host_tool_result.swift @@ -0,0 +1,118 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Result returned by a host tool executor. +public struct HostToolResult: TypraModel { + public var requestId: String? = nil + public var toolCallId: String? = nil + public var toolName: String = "" + public var success: Bool = false + public var result: Any? = nil + public var exitCode: Int32? = nil + public var durationMs: Double? = nil + public var errorKind: String? = nil + public var telemetry: [String: Any]? = nil + + public init( + requestId: String? = nil, toolCallId: String? = nil, toolName: String = "", + success: Bool = false, result: Any? = nil, exitCode: Int32? = nil, durationMs: Double? = nil, + errorKind: String? = nil, telemetry: [String: Any]? = nil + ) { + self.requestId = requestId + self.toolCallId = toolCallId + self.toolName = toolName + self.success = success + self.result = result + self.exitCode = exitCode + self.durationMs = durationMs + self.errorKind = errorKind + self.telemetry = telemetry + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> HostToolResult + { + let object = try TypraRuntime.object(data, typeName: "HostToolResult") + var instance = HostToolResult() + if let value = object["requestId"] { + instance.requestId = try TypraRuntime.string(value, field: "requestId") + } + if let value = object["toolCallId"] { + instance.toolCallId = try TypraRuntime.string(value, field: "toolCallId") + } + if let value = object["toolName"] { + instance.toolName = try TypraRuntime.string(value, field: "toolName") + } + if let value = object["success"] { + instance.success = try TypraRuntime.bool(value, field: "success") + } + if let value = object["result"] { + instance.result = value + } + if let value = object["exitCode"] { + instance.exitCode = try TypraRuntime.int32(value, field: "exitCode") + } + if let value = object["durationMs"] { + instance.durationMs = try TypraRuntime.double(value, field: "durationMs") + } + if let value = object["errorKind"] { + instance.errorKind = try TypraRuntime.string(value, field: "errorKind") + } + if let value = object["telemetry"] { + instance.telemetry = try TypraRuntime.dictionary(value, field: "telemetry") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.requestId { + result["requestId"] = value + } + if let value = self.toolCallId { + result["toolCallId"] = value + } + result["toolName"] = self.toolName + result["success"] = self.success + if let value = self.result { + result["result"] = value + } + if let value = self.exitCode { + result["exitCode"] = value + } + if let value = self.durationMs { + result["durationMs"] = value + } + if let value = self.errorKind { + result["errorKind"] = value + } + if let value = self.telemetry { + result["telemetry"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> HostToolResult + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "HostToolResult"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> HostToolResult + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "HostToolResult"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/llm_complete_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/llm_complete_payload.swift new file mode 100644 index 000000000..332940d7c --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/llm_complete_payload.swift @@ -0,0 +1,81 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "llm_complete" events — an LLM request completed. +public struct LlmCompletePayload: TypraModel { + public var requestId: String? = nil + public var serviceRequestId: String? = nil + public var usage: TokenUsage? = nil + public var durationMs: Double? = nil + + public init( + requestId: String? = nil, serviceRequestId: String? = nil, usage: TokenUsage? = nil, + durationMs: Double? = nil + ) { + self.requestId = requestId + self.serviceRequestId = serviceRequestId + self.usage = usage + self.durationMs = durationMs + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> LlmCompletePayload + { + let object = try TypraRuntime.object(data, typeName: "LlmCompletePayload") + var instance = LlmCompletePayload() + if let value = object["requestId"] { + instance.requestId = try TypraRuntime.string(value, field: "requestId") + } + if let value = object["serviceRequestId"] { + instance.serviceRequestId = try TypraRuntime.string(value, field: "serviceRequestId") + } + if let value = object["usage"] { + instance.usage = try TokenUsage.load(value, context: context) + } + if let value = object["durationMs"] { + instance.durationMs = try TypraRuntime.double(value, field: "durationMs") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.requestId { + result["requestId"] = value + } + if let value = self.serviceRequestId { + result["serviceRequestId"] = value + } + if let value = self.usage { + result["usage"] = try value.save(context) + } + if let value = self.durationMs { + result["durationMs"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> LlmCompletePayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "LlmCompletePayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> LlmCompletePayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "LlmCompletePayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/llm_start_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/llm_start_payload.swift new file mode 100644 index 000000000..d450204c9 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/llm_start_payload.swift @@ -0,0 +1,81 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "llm_start" events — an LLM request is about to be sent. +public struct LlmStartPayload: TypraModel { + public var provider: String? = nil + public var modelId: String? = nil + public var messageCount: Int32? = nil + public var attempt: Int32? = nil + + public init( + provider: String? = nil, modelId: String? = nil, messageCount: Int32? = nil, + attempt: Int32? = nil + ) { + self.provider = provider + self.modelId = modelId + self.messageCount = messageCount + self.attempt = attempt + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> LlmStartPayload + { + let object = try TypraRuntime.object(data, typeName: "LlmStartPayload") + var instance = LlmStartPayload() + if let value = object["provider"] { + instance.provider = try TypraRuntime.string(value, field: "provider") + } + if let value = object["modelId"] { + instance.modelId = try TypraRuntime.string(value, field: "modelId") + } + if let value = object["messageCount"] { + instance.messageCount = try TypraRuntime.int32(value, field: "messageCount") + } + if let value = object["attempt"] { + instance.attempt = try TypraRuntime.int32(value, field: "attempt") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.provider { + result["provider"] = value + } + if let value = self.modelId { + result["modelId"] = value + } + if let value = self.messageCount { + result["messageCount"] = value + } + if let value = self.attempt { + result["attempt"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> LlmStartPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "LlmStartPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> LlmStartPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "LlmStartPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/messages_updated_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/messages_updated_payload.swift new file mode 100644 index 000000000..d3fa67156 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/messages_updated_payload.swift @@ -0,0 +1,85 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "messages_updated" events — the conversation state has changed. +public struct MessagesUpdatedPayload: TypraModel { + public var messages: [Message]? = nil + public var reason: String? = nil + public var appended: [Message]? = nil + public var removed: Int32? = nil + + public init( + messages: [Message]? = nil, reason: String? = nil, appended: [Message]? = nil, + removed: Int32? = nil + ) { + self.messages = messages + self.reason = reason + self.appended = appended + self.removed = removed + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> MessagesUpdatedPayload + { + let object = try TypraRuntime.object(data, typeName: "MessagesUpdatedPayload") + var instance = MessagesUpdatedPayload() + if let value = object["messages"] { + instance.messages = try TypraRuntime.array(value, field: "messages").map { + try Message.load($0, context: context) + } + } + if let value = object["reason"] { + instance.reason = try TypraRuntime.string(value, field: "reason") + } + if let value = object["appended"] { + instance.appended = try TypraRuntime.array(value, field: "appended").map { + try Message.load($0, context: context) + } + } + if let value = object["removed"] { + instance.removed = try TypraRuntime.int32(value, field: "removed") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.messages { + result["messages"] = try value.map { try $0.save(context) } + } + if let value = self.reason { + result["reason"] = value + } + if let value = self.appended { + result["appended"] = try value.map { try $0.save(context) } + } + if let value = self.removed { + result["removed"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> MessagesUpdatedPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "MessagesUpdatedPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> MessagesUpdatedPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "MessagesUpdatedPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/permission_completed_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/permission_completed_payload.swift new file mode 100644 index 000000000..26884caea --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/permission_completed_payload.swift @@ -0,0 +1,102 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for permission completion events — an approval decision was made. +public struct PermissionCompletedPayload: TypraModel { + public var requestId: String? = nil + public var toolCallId: String? = nil + public var permission: String = "" + public var approved: Bool = false + public var reason: String? = nil + public var result: [String: Any]? = nil + public var redaction: RedactionMetadata? = nil + + public init( + requestId: String? = nil, toolCallId: String? = nil, permission: String = "", + approved: Bool = false, reason: String? = nil, result: [String: Any]? = nil, + redaction: RedactionMetadata? = nil + ) { + self.requestId = requestId + self.toolCallId = toolCallId + self.permission = permission + self.approved = approved + self.reason = reason + self.result = result + self.redaction = redaction + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> PermissionCompletedPayload + { + let object = try TypraRuntime.object(data, typeName: "PermissionCompletedPayload") + var instance = PermissionCompletedPayload() + if let value = object["requestId"] { + instance.requestId = try TypraRuntime.string(value, field: "requestId") + } + if let value = object["toolCallId"] { + instance.toolCallId = try TypraRuntime.string(value, field: "toolCallId") + } + if let value = object["permission"] { + instance.permission = try TypraRuntime.string(value, field: "permission") + } + if let value = object["approved"] { + instance.approved = try TypraRuntime.bool(value, field: "approved") + } + if let value = object["reason"] { + instance.reason = try TypraRuntime.string(value, field: "reason") + } + if let value = object["result"] { + instance.result = try TypraRuntime.dictionary(value, field: "result") + } + if let value = object["redaction"] { + instance.redaction = try RedactionMetadata.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.requestId { + result["requestId"] = value + } + if let value = self.toolCallId { + result["toolCallId"] = value + } + result["permission"] = self.permission + result["approved"] = self.approved + if let value = self.reason { + result["reason"] = value + } + if let value = self.result { + result["result"] = value + } + if let value = self.redaction { + result["redaction"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> PermissionCompletedPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "PermissionCompletedPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> PermissionCompletedPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "PermissionCompletedPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/permission_decision.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/permission_decision.swift new file mode 100644 index 000000000..056ff033b --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/permission_decision.swift @@ -0,0 +1,93 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Decision returned by a permission resolver. +public struct PermissionDecision: TypraModel { + public var requestId: String? = nil + public var toolCallId: String? = nil + public var permission: String = "" + public var approved: Bool = false + public var reason: String? = nil + public var result: [String: Any]? = nil + + public init( + requestId: String? = nil, toolCallId: String? = nil, permission: String = "", + approved: Bool = false, reason: String? = nil, result: [String: Any]? = nil + ) { + self.requestId = requestId + self.toolCallId = toolCallId + self.permission = permission + self.approved = approved + self.reason = reason + self.result = result + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> PermissionDecision + { + let object = try TypraRuntime.object(data, typeName: "PermissionDecision") + var instance = PermissionDecision() + if let value = object["requestId"] { + instance.requestId = try TypraRuntime.string(value, field: "requestId") + } + if let value = object["toolCallId"] { + instance.toolCallId = try TypraRuntime.string(value, field: "toolCallId") + } + if let value = object["permission"] { + instance.permission = try TypraRuntime.string(value, field: "permission") + } + if let value = object["approved"] { + instance.approved = try TypraRuntime.bool(value, field: "approved") + } + if let value = object["reason"] { + instance.reason = try TypraRuntime.string(value, field: "reason") + } + if let value = object["result"] { + instance.result = try TypraRuntime.dictionary(value, field: "result") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.requestId { + result["requestId"] = value + } + if let value = self.toolCallId { + result["toolCallId"] = value + } + result["permission"] = self.permission + result["approved"] = self.approved + if let value = self.reason { + result["reason"] = value + } + if let value = self.result { + result["result"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> PermissionDecision + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "PermissionDecision"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> PermissionDecision + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "PermissionDecision"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/permission_request.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/permission_request.swift new file mode 100644 index 000000000..1f2c670cd --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/permission_request.swift @@ -0,0 +1,104 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Request passed to a permission resolver. This is the live protocol shape; the event payloads above can include trace-only metadata such as redaction state. +public struct PermissionRequest: TypraModel { + public var requestId: String? = nil + public var toolCallId: String? = nil + public var permission: String = "" + public var target: String? = nil + public var details: [String: Any]? = nil + public var promptRequest: String? = nil + public var policy: [String: Any]? = nil + + public init( + requestId: String? = nil, toolCallId: String? = nil, permission: String = "", + target: String? = nil, details: [String: Any]? = nil, promptRequest: String? = nil, + policy: [String: Any]? = nil + ) { + self.requestId = requestId + self.toolCallId = toolCallId + self.permission = permission + self.target = target + self.details = details + self.promptRequest = promptRequest + self.policy = policy + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> PermissionRequest + { + let object = try TypraRuntime.object(data, typeName: "PermissionRequest") + var instance = PermissionRequest() + if let value = object["requestId"] { + instance.requestId = try TypraRuntime.string(value, field: "requestId") + } + if let value = object["toolCallId"] { + instance.toolCallId = try TypraRuntime.string(value, field: "toolCallId") + } + if let value = object["permission"] { + instance.permission = try TypraRuntime.string(value, field: "permission") + } + if let value = object["target"] { + instance.target = try TypraRuntime.string(value, field: "target") + } + if let value = object["details"] { + instance.details = try TypraRuntime.dictionary(value, field: "details") + } + if let value = object["promptRequest"] { + instance.promptRequest = try TypraRuntime.string(value, field: "promptRequest") + } + if let value = object["policy"] { + instance.policy = try TypraRuntime.dictionary(value, field: "policy") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.requestId { + result["requestId"] = value + } + if let value = self.toolCallId { + result["toolCallId"] = value + } + result["permission"] = self.permission + if let value = self.target { + result["target"] = value + } + if let value = self.details { + result["details"] = value + } + if let value = self.promptRequest { + result["promptRequest"] = value + } + if let value = self.policy { + result["policy"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> PermissionRequest + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "PermissionRequest"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> PermissionRequest + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "PermissionRequest"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/permission_requested_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/permission_requested_payload.swift new file mode 100644 index 000000000..3c6856cea --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/permission_requested_payload.swift @@ -0,0 +1,112 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for permission request events — a host is asked to approve an action. +public struct PermissionRequestedPayload: TypraModel { + public var requestId: String? = nil + public var toolCallId: String? = nil + public var permission: String = "" + public var target: String? = nil + public var details: [String: Any]? = nil + public var promptRequest: String? = nil + public var policy: [String: Any]? = nil + public var redaction: RedactionMetadata? = nil + + public init( + requestId: String? = nil, toolCallId: String? = nil, permission: String = "", + target: String? = nil, details: [String: Any]? = nil, promptRequest: String? = nil, + policy: [String: Any]? = nil, redaction: RedactionMetadata? = nil + ) { + self.requestId = requestId + self.toolCallId = toolCallId + self.permission = permission + self.target = target + self.details = details + self.promptRequest = promptRequest + self.policy = policy + self.redaction = redaction + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> PermissionRequestedPayload + { + let object = try TypraRuntime.object(data, typeName: "PermissionRequestedPayload") + var instance = PermissionRequestedPayload() + if let value = object["requestId"] { + instance.requestId = try TypraRuntime.string(value, field: "requestId") + } + if let value = object["toolCallId"] { + instance.toolCallId = try TypraRuntime.string(value, field: "toolCallId") + } + if let value = object["permission"] { + instance.permission = try TypraRuntime.string(value, field: "permission") + } + if let value = object["target"] { + instance.target = try TypraRuntime.string(value, field: "target") + } + if let value = object["details"] { + instance.details = try TypraRuntime.dictionary(value, field: "details") + } + if let value = object["promptRequest"] { + instance.promptRequest = try TypraRuntime.string(value, field: "promptRequest") + } + if let value = object["policy"] { + instance.policy = try TypraRuntime.dictionary(value, field: "policy") + } + if let value = object["redaction"] { + instance.redaction = try RedactionMetadata.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.requestId { + result["requestId"] = value + } + if let value = self.toolCallId { + result["toolCallId"] = value + } + result["permission"] = self.permission + if let value = self.target { + result["target"] = value + } + if let value = self.details { + result["details"] = value + } + if let value = self.promptRequest { + result["promptRequest"] = value + } + if let value = self.policy { + result["policy"] = value + } + if let value = self.redaction { + result["redaction"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> PermissionRequestedPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "PermissionRequestedPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> PermissionRequestedPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "PermissionRequestedPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/redacted_field.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/redacted_field.swift new file mode 100644 index 000000000..cadbabd55 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/redacted_field.swift @@ -0,0 +1,86 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum RedactionMode: String, Codable, CaseIterable { + case `none` = "none" + case redacted = "redacted" + case hashed = "hashed" + case summary = "summary" + case reference = "reference" + public static func parse(_ value: String) throws -> RedactionMode { + switch value { + case "none": return .`none` + case "redacted": return .redacted + case "hashed": return .hashed + case "summary": return .summary + case "reference": return .reference + default: throw TypraRuntimeError.invalidEnum(type: "RedactionMode", value: value) + } + } +} + +/// Redaction handling for one JSON-shaped field path. +public struct RedactedField: TypraModel { + public var path: String = "" + public var mode: RedactionMode = (try! RedactionMode.parse("none")) + public var reason: String? = nil + + public init( + path: String = "", mode: RedactionMode = (try! RedactionMode.parse("none")), + reason: String? = nil + ) { + self.path = path + self.mode = mode + self.reason = reason + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> RedactedField + { + let object = try TypraRuntime.object(data, typeName: "RedactedField") + var instance = RedactedField() + if let value = object["path"] { + instance.path = try TypraRuntime.string(value, field: "path") + } + if let value = object["mode"] { + instance.mode = try RedactionMode.parse(try TypraRuntime.string(value, field: "mode")) + } + if let value = object["reason"] { + instance.reason = try TypraRuntime.string(value, field: "reason") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["path"] = self.path + result["mode"] = self.mode.rawValue + if let value = self.reason { + result["reason"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> RedactedField + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "RedactedField"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> RedactedField + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "RedactedField"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/redaction_metadata.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/redaction_metadata.swift new file mode 100644 index 000000000..61c8d6170 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/redaction_metadata.swift @@ -0,0 +1,72 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Metadata describing whether and how a payload was sanitized. +public struct RedactionMetadata: TypraModel { + public var sanitized: Bool? = nil + public var fields: [RedactedField]? = nil + public var policy: String? = nil + + public init(sanitized: Bool? = nil, fields: [RedactedField]? = nil, policy: String? = nil) { + self.sanitized = sanitized + self.fields = fields + self.policy = policy + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> RedactionMetadata + { + let object = try TypraRuntime.object(data, typeName: "RedactionMetadata") + var instance = RedactionMetadata() + if let value = object["sanitized"] { + instance.sanitized = try TypraRuntime.bool(value, field: "sanitized") + } + if let value = object["fields"] { + instance.fields = try TypraRuntime.array(value, field: "fields").map { + try RedactedField.load($0, context: context) + } + } + if let value = object["policy"] { + instance.policy = try TypraRuntime.string(value, field: "policy") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.sanitized { + result["sanitized"] = value + } + if let value = self.fields { + result["fields"] = try value.map { try $0.save(context) } + } + if let value = self.policy { + result["policy"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> RedactionMetadata + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "RedactionMetadata"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> RedactionMetadata + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "RedactionMetadata"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/retry_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/retry_payload.swift new file mode 100644 index 000000000..5eb68915d --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/retry_payload.swift @@ -0,0 +1,82 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "retry" events — a transient operation will be retried. +public struct RetryPayload: TypraModel { + public var operation: String = "" + public var attempt: Int32 = 0 + public var maxAttempts: Int32? = nil + public var delayMs: Double? = nil + public var reason: String? = nil + + public init( + operation: String = "", attempt: Int32 = 0, maxAttempts: Int32? = nil, delayMs: Double? = nil, + reason: String? = nil + ) { + self.operation = operation + self.attempt = attempt + self.maxAttempts = maxAttempts + self.delayMs = delayMs + self.reason = reason + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> RetryPayload + { + let object = try TypraRuntime.object(data, typeName: "RetryPayload") + var instance = RetryPayload() + if let value = object["operation"] { + instance.operation = try TypraRuntime.string(value, field: "operation") + } + if let value = object["attempt"] { + instance.attempt = try TypraRuntime.int32(value, field: "attempt") + } + if let value = object["maxAttempts"] { + instance.maxAttempts = try TypraRuntime.int32(value, field: "maxAttempts") + } + if let value = object["delayMs"] { + instance.delayMs = try TypraRuntime.double(value, field: "delayMs") + } + if let value = object["reason"] { + instance.reason = try TypraRuntime.string(value, field: "reason") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["operation"] = self.operation + result["attempt"] = self.attempt + if let value = self.maxAttempts { + result["maxAttempts"] = value + } + if let value = self.delayMs { + result["delayMs"] = value + } + if let value = self.reason { + result["reason"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> RetryPayload + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "RetryPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> RetryPayload + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "RetryPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/session_end_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_end_payload.swift new file mode 100644 index 000000000..1eafa2e3b --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_end_payload.swift @@ -0,0 +1,97 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum SessionEndStatus: String, Codable, CaseIterable { + case success = "success" + case error = "error" + case cancelled = "cancelled" + case interrupted = "interrupted" + public static func parse(_ value: String) throws -> SessionEndStatus { + switch value { + case "success": return .success + case "error": return .error + case "cancelled": return .cancelled + case "interrupted": return .interrupted + default: throw TypraRuntimeError.invalidEnum(type: "SessionEndStatus", value: value) + } + } +} + +/// Payload for "session_end" events. +public struct SessionEndPayload: TypraModel { + public var sessionId: String? = nil + public var status: SessionEndStatus? = nil + public var reason: String? = nil + public var durationMs: Double? = nil + + public init( + sessionId: String? = nil, status: SessionEndStatus? = nil, reason: String? = nil, + durationMs: Double? = nil + ) { + self.sessionId = sessionId + self.status = status + self.reason = reason + self.durationMs = durationMs + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> SessionEndPayload + { + let object = try TypraRuntime.object(data, typeName: "SessionEndPayload") + var instance = SessionEndPayload() + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["status"] { + instance.status = try SessionEndStatus.parse(try TypraRuntime.string(value, field: "status")) + } + if let value = object["reason"] { + instance.reason = try TypraRuntime.string(value, field: "reason") + } + if let value = object["durationMs"] { + instance.durationMs = try TypraRuntime.double(value, field: "durationMs") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.sessionId { + result["sessionId"] = value + } + if let value = self.status { + result["status"] = value.rawValue + } + if let value = self.reason { + result["reason"] = value + } + if let value = self.durationMs { + result["durationMs"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> SessionEndPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "SessionEndPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> SessionEndPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "SessionEndPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/session_event.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_event.swift new file mode 100644 index 000000000..2ac4c3338 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_event.swift @@ -0,0 +1,134 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum SessionEventType: String, Codable, CaseIterable { + case sessionStart = "session_start" + case sessionEnd = "session_end" + case sessionWarning = "session_warning" + case sessionHookStart = "session_hook_start" + case sessionHookEnd = "session_hook_end" + case checkpointCreated = "checkpoint_created" + case trajectoryEvent = "trajectory_event" + public static func parse(_ value: String) throws -> SessionEventType { + switch value { + case "session_start": return .sessionStart + case "session_end": return .sessionEnd + case "session_warning": return .sessionWarning + case "session_hook_start": return .sessionHookStart + case "session_hook_end": return .sessionHookEnd + case "checkpoint_created": return .checkpointCreated + case "trajectory_event": return .trajectoryEvent + default: throw TypraRuntimeError.invalidEnum(type: "SessionEventType", value: value) + } + } +} + +/// A canonical event envelope emitted by an outer harness session. +public struct SessionEvent: TypraModel { + public var id: String = "" + public var type: SessionEventType = (try! SessionEventType.parse("session_start")) + public var timestamp: String = "" + public var sessionId: String? = nil + public var turnId: String? = nil + public var parentId: String? = nil + public var spanId: String? = nil + public var payload: [String: Any] = [:] + public var redaction: RedactionMetadata? = nil + + public init( + id: String = "", type: SessionEventType = (try! SessionEventType.parse("session_start")), + timestamp: String = "", sessionId: String? = nil, turnId: String? = nil, + parentId: String? = nil, spanId: String? = nil, payload: [String: Any] = [:], + redaction: RedactionMetadata? = nil + ) { + self.id = id + self.type = type + self.timestamp = timestamp + self.sessionId = sessionId + self.turnId = turnId + self.parentId = parentId + self.spanId = spanId + self.payload = payload + self.redaction = redaction + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> SessionEvent + { + let object = try TypraRuntime.object(data, typeName: "SessionEvent") + var instance = SessionEvent() + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["type"] { + instance.type = try SessionEventType.parse(try TypraRuntime.string(value, field: "type")) + } + if let value = object["timestamp"] { + instance.timestamp = try TypraRuntime.string(value, field: "timestamp") + } + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["parentId"] { + instance.parentId = try TypraRuntime.string(value, field: "parentId") + } + if let value = object["spanId"] { + instance.spanId = try TypraRuntime.string(value, field: "spanId") + } + if let value = object["payload"] { + instance.payload = try TypraRuntime.dictionary(value, field: "payload") + } + if let value = object["redaction"] { + instance.redaction = try RedactionMetadata.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["id"] = self.id + result["type"] = self.type.rawValue + result["timestamp"] = self.timestamp + if let value = self.sessionId { + result["sessionId"] = value + } + if let value = self.turnId { + result["turnId"] = value + } + if let value = self.parentId { + result["parentId"] = value + } + if let value = self.spanId { + result["spanId"] = value + } + result["payload"] = self.payload + if let value = self.redaction { + result["redaction"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> SessionEvent + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "SessionEvent"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> SessionEvent + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "SessionEvent"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/session_file_ref.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_file_ref.swift new file mode 100644 index 000000000..79ed43d8b --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_file_ref.swift @@ -0,0 +1,87 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A file observed or touched by a harness session. +public struct SessionFileRef: TypraModel { + public var sessionId: String? = nil + public var path: String = "" + public var toolName: String? = nil + public var turnIndex: Int32? = nil + public var firstSeenAt: String? = nil + + public init( + sessionId: String? = nil, path: String = "", toolName: String? = nil, turnIndex: Int32? = nil, + firstSeenAt: String? = nil + ) { + self.sessionId = sessionId + self.path = path + self.toolName = toolName + self.turnIndex = turnIndex + self.firstSeenAt = firstSeenAt + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> SessionFileRef + { + let object = try TypraRuntime.object(data, typeName: "SessionFileRef") + var instance = SessionFileRef() + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["path"] { + instance.path = try TypraRuntime.string(value, field: "path") + } + if let value = object["toolName"] { + instance.toolName = try TypraRuntime.string(value, field: "toolName") + } + if let value = object["turnIndex"] { + instance.turnIndex = try TypraRuntime.int32(value, field: "turnIndex") + } + if let value = object["firstSeenAt"] { + instance.firstSeenAt = try TypraRuntime.string(value, field: "firstSeenAt") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.sessionId { + result["sessionId"] = value + } + result["path"] = self.path + if let value = self.toolName { + result["toolName"] = value + } + if let value = self.turnIndex { + result["turnIndex"] = value + } + if let value = self.firstSeenAt { + result["firstSeenAt"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> SessionFileRef + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "SessionFileRef"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> SessionFileRef + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "SessionFileRef"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/session_ref.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_ref.swift new file mode 100644 index 000000000..758945643 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_ref.swift @@ -0,0 +1,81 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A non-file reference observed by a harness session. +public struct SessionRef: TypraModel { + public var sessionId: String? = nil + public var refType: String = "" + public var refValue: String = "" + public var turnIndex: Int32? = nil + public var createdAt: String? = nil + + public init( + sessionId: String? = nil, refType: String = "", refValue: String = "", turnIndex: Int32? = nil, + createdAt: String? = nil + ) { + self.sessionId = sessionId + self.refType = refType + self.refValue = refValue + self.turnIndex = turnIndex + self.createdAt = createdAt + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> SessionRef { + let object = try TypraRuntime.object(data, typeName: "SessionRef") + var instance = SessionRef() + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["refType"] { + instance.refType = try TypraRuntime.string(value, field: "refType") + } + if let value = object["refValue"] { + instance.refValue = try TypraRuntime.string(value, field: "refValue") + } + if let value = object["turnIndex"] { + instance.turnIndex = try TypraRuntime.int32(value, field: "turnIndex") + } + if let value = object["createdAt"] { + instance.createdAt = try TypraRuntime.string(value, field: "createdAt") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.sessionId { + result["sessionId"] = value + } + result["refType"] = self.refType + result["refValue"] = self.refValue + if let value = self.turnIndex { + result["turnIndex"] = value + } + if let value = self.createdAt { + result["createdAt"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> SessionRef + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "SessionRef"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> SessionRef + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "SessionRef"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/session_start_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_start_payload.swift new file mode 100644 index 000000000..96f19dee9 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_start_payload.swift @@ -0,0 +1,120 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "session_start" events. +public struct SessionStartPayload: TypraModel { + public var sessionId: String = "" + public var schemaVersion: String? = nil + public var producer: String? = nil + public var runtime: String? = nil + public var promptyVersion: String? = nil + public var startTime: String? = nil + public var selectedModel: String? = nil + public var reasoningEffort: String? = nil + public var context: HarnessContext? = nil + + public init( + sessionId: String = "", schemaVersion: String? = nil, producer: String? = nil, + runtime: String? = nil, promptyVersion: String? = nil, startTime: String? = nil, + selectedModel: String? = nil, reasoningEffort: String? = nil, context: HarnessContext? = nil + ) { + self.sessionId = sessionId + self.schemaVersion = schemaVersion + self.producer = producer + self.runtime = runtime + self.promptyVersion = promptyVersion + self.startTime = startTime + self.selectedModel = selectedModel + self.reasoningEffort = reasoningEffort + self.context = context + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> SessionStartPayload + { + let object = try TypraRuntime.object(data, typeName: "SessionStartPayload") + var instance = SessionStartPayload() + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["schemaVersion"] { + instance.schemaVersion = try TypraRuntime.string(value, field: "schemaVersion") + } + if let value = object["producer"] { + instance.producer = try TypraRuntime.string(value, field: "producer") + } + if let value = object["runtime"] { + instance.runtime = try TypraRuntime.string(value, field: "runtime") + } + if let value = object["promptyVersion"] { + instance.promptyVersion = try TypraRuntime.string(value, field: "promptyVersion") + } + if let value = object["startTime"] { + instance.startTime = try TypraRuntime.string(value, field: "startTime") + } + if let value = object["selectedModel"] { + instance.selectedModel = try TypraRuntime.string(value, field: "selectedModel") + } + if let value = object["reasoningEffort"] { + instance.reasoningEffort = try TypraRuntime.string(value, field: "reasoningEffort") + } + if let value = object["context"] { + instance.context = try HarnessContext.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["sessionId"] = self.sessionId + if let value = self.schemaVersion { + result["schemaVersion"] = value + } + if let value = self.producer { + result["producer"] = value + } + if let value = self.runtime { + result["runtime"] = value + } + if let value = self.promptyVersion { + result["promptyVersion"] = value + } + if let value = self.startTime { + result["startTime"] = value + } + if let value = self.selectedModel { + result["selectedModel"] = value + } + if let value = self.reasoningEffort { + result["reasoningEffort"] = value + } + if let value = self.context { + result["context"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> SessionStartPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "SessionStartPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> SessionStartPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "SessionStartPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/session_summary.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_summary.swift new file mode 100644 index 000000000..836c73ea5 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_summary.swift @@ -0,0 +1,112 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum SessionSummaryStatus: String, Codable, CaseIterable { + case success = "success" + case error = "error" + case cancelled = "cancelled" + case interrupted = "interrupted" + public static func parse(_ value: String) throws -> SessionSummaryStatus { + switch value { + case "success": return .success + case "error": return .error + case "cancelled": return .cancelled + case "interrupted": return .interrupted + default: throw TypraRuntimeError.invalidEnum(type: "SessionSummaryStatus", value: value) + } + } +} + +/// Summary statistics for a completed session trace. +public struct SessionSummary: TypraModel { + public var sessionId: String = "" + public var status: SessionSummaryStatus? = nil + public var turns: Int32? = nil + public var checkpoints: Int32? = nil + public var usage: TokenUsage? = nil + public var durationMs: Double? = nil + + public init( + sessionId: String = "", status: SessionSummaryStatus? = nil, turns: Int32? = nil, + checkpoints: Int32? = nil, usage: TokenUsage? = nil, durationMs: Double? = nil + ) { + self.sessionId = sessionId + self.status = status + self.turns = turns + self.checkpoints = checkpoints + self.usage = usage + self.durationMs = durationMs + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> SessionSummary + { + let object = try TypraRuntime.object(data, typeName: "SessionSummary") + var instance = SessionSummary() + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["status"] { + instance.status = try SessionSummaryStatus.parse( + try TypraRuntime.string(value, field: "status")) + } + if let value = object["turns"] { + instance.turns = try TypraRuntime.int32(value, field: "turns") + } + if let value = object["checkpoints"] { + instance.checkpoints = try TypraRuntime.int32(value, field: "checkpoints") + } + if let value = object["usage"] { + instance.usage = try TokenUsage.load(value, context: context) + } + if let value = object["durationMs"] { + instance.durationMs = try TypraRuntime.double(value, field: "durationMs") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["sessionId"] = self.sessionId + if let value = self.status { + result["status"] = value.rawValue + } + if let value = self.turns { + result["turns"] = value + } + if let value = self.checkpoints { + result["checkpoints"] = value + } + if let value = self.usage { + result["usage"] = try value.save(context) + } + if let value = self.durationMs { + result["durationMs"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> SessionSummary + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "SessionSummary"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> SessionSummary + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "SessionSummary"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/session_trace.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_trace.swift new file mode 100644 index 000000000..a5d4bc92c --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_trace.swift @@ -0,0 +1,146 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Portable replay container for an outer harness session. +public struct SessionTrace: TypraModel { + public var version: String = "1" + public var runtime: String? = nil + public var promptyVersion: String? = nil + public var sessionId: String? = nil + public var events: [SessionEvent] = [] + public var turns: [TurnTrace]? = nil + public var checkpoints: [Checkpoint]? = nil + public var trajectory: [TrajectoryEvent]? = nil + public var files: [SessionFileRef]? = nil + public var refs: [SessionRef]? = nil + public var summary: SessionSummary? = nil + + public init( + version: String = "1", runtime: String? = nil, promptyVersion: String? = nil, + sessionId: String? = nil, events: [SessionEvent] = [], turns: [TurnTrace]? = nil, + checkpoints: [Checkpoint]? = nil, trajectory: [TrajectoryEvent]? = nil, + files: [SessionFileRef]? = nil, refs: [SessionRef]? = nil, summary: SessionSummary? = nil + ) { + self.version = version + self.runtime = runtime + self.promptyVersion = promptyVersion + self.sessionId = sessionId + self.events = events + self.turns = turns + self.checkpoints = checkpoints + self.trajectory = trajectory + self.files = files + self.refs = refs + self.summary = summary + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> SessionTrace + { + let object = try TypraRuntime.object(data, typeName: "SessionTrace") + var instance = SessionTrace() + if let value = object["version"] { + instance.version = try TypraRuntime.string(value, field: "version") + } else { + instance.version = "1" + } + if let value = object["runtime"] { + instance.runtime = try TypraRuntime.string(value, field: "runtime") + } + if let value = object["promptyVersion"] { + instance.promptyVersion = try TypraRuntime.string(value, field: "promptyVersion") + } + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["events"] { + instance.events = try TypraRuntime.array(value, field: "events").map { + try SessionEvent.load($0, context: context) + } + } + if let value = object["turns"] { + instance.turns = try TypraRuntime.array(value, field: "turns").map { + try TurnTrace.load($0, context: context) + } + } + if let value = object["checkpoints"] { + instance.checkpoints = try TypraRuntime.array(value, field: "checkpoints").map { + try Checkpoint.load($0, context: context) + } + } + if let value = object["trajectory"] { + instance.trajectory = try TypraRuntime.array(value, field: "trajectory").map { + try TrajectoryEvent.load($0, context: context) + } + } + if let value = object["files"] { + instance.files = try TypraRuntime.array(value, field: "files").map { + try SessionFileRef.load($0, context: context) + } + } + if let value = object["refs"] { + instance.refs = try TypraRuntime.array(value, field: "refs").map { + try SessionRef.load($0, context: context) + } + } + if let value = object["summary"] { + instance.summary = try SessionSummary.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["version"] = self.version + if let value = self.runtime { + result["runtime"] = value + } + if let value = self.promptyVersion { + result["promptyVersion"] = value + } + if let value = self.sessionId { + result["sessionId"] = value + } + result["events"] = try self.events.map { try $0.save(context) } + if let value = self.turns { + result["turns"] = try value.map { try $0.save(context) } + } + if let value = self.checkpoints { + result["checkpoints"] = try value.map { try $0.save(context) } + } + if let value = self.trajectory { + result["trajectory"] = try value.map { try $0.save(context) } + } + if let value = self.files { + result["files"] = try value.map { try $0.save(context) } + } + if let value = self.refs { + result["refs"] = try value.map { try $0.save(context) } + } + if let value = self.summary { + result["summary"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> SessionTrace + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "SessionTrace"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> SessionTrace + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "SessionTrace"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/session_warning_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_warning_payload.swift new file mode 100644 index 000000000..61a43f5fa --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/session_warning_payload.swift @@ -0,0 +1,66 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "session_warning" events. +public struct SessionWarningPayload: TypraModel { + public var warningType: String = "" + public var message: String = "" + public var details: [String: Any]? = nil + + public init(warningType: String = "", message: String = "", details: [String: Any]? = nil) { + self.warningType = warningType + self.message = message + self.details = details + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> SessionWarningPayload + { + let object = try TypraRuntime.object(data, typeName: "SessionWarningPayload") + var instance = SessionWarningPayload() + if let value = object["warningType"] { + instance.warningType = try TypraRuntime.string(value, field: "warningType") + } + if let value = object["message"] { + instance.message = try TypraRuntime.string(value, field: "message") + } + if let value = object["details"] { + instance.details = try TypraRuntime.dictionary(value, field: "details") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["warningType"] = self.warningType + result["message"] = self.message + if let value = self.details { + result["details"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> SessionWarningPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "SessionWarningPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> SessionWarningPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "SessionWarningPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/status_event_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/status_event_payload.swift new file mode 100644 index 000000000..f6dba6667 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/status_event_payload.swift @@ -0,0 +1,52 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "status" events — informational messages about loop progress. +public struct StatusEventPayload: TypraModel { + public var message: String = "" + + public init(message: String = "") { + self.message = message + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> StatusEventPayload + { + let object = try TypraRuntime.object(data, typeName: "StatusEventPayload") + var instance = StatusEventPayload() + if let value = object["message"] { + instance.message = try TypraRuntime.string(value, field: "message") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["message"] = self.message + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> StatusEventPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "StatusEventPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> StatusEventPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "StatusEventPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/stream_chunk.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/stream_chunk.swift new file mode 100644 index 000000000..7c53a5861 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/stream_chunk.swift @@ -0,0 +1,320 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum StreamChunk: TypraModel { + case textChunk(TextChunk) + case thinkingChunk(ThinkingChunk) + case toolChunk(ToolChunk) + case usageChunk(UsageChunk) + case errorChunk(ErrorChunk) + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> StreamChunk { + let object = try TypraRuntime.object(data, typeName: "StreamChunk") + let discriminator = try TypraRuntime.string(object["kind"] ?? "", field: "kind") + switch discriminator { + case "text": return .textChunk(try TextChunk.load(data, context: context)) + case "thinking": return .thinkingChunk(try ThinkingChunk.load(data, context: context)) + case "tool": return .toolChunk(try ToolChunk.load(data, context: context)) + case "usage": return .usageChunk(try UsageChunk.load(data, context: context)) + case "error": return .errorChunk(try ErrorChunk.load(data, context: context)) + default: + throw TypraRuntimeError.unknownDiscriminator( + type: "StreamChunk", field: "kind", value: discriminator) + } + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + switch self { + case .textChunk(let value): return try value.save(context) + case .thinkingChunk(let value): return try value.save(context) + case .toolChunk(let value): return try value.save(context) + case .usageChunk(let value): return try value.save(context) + case .errorChunk(let value): return try value.save(context) + } + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> StreamChunk + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "StreamChunk"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> StreamChunk + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "StreamChunk"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// A text content chunk from the LLM response stream. +public struct TextChunk: TypraModel { + public var kind: String = "text" + public var value: String = "" + + public init(kind: String = "text", value: String = "") { + self.kind = kind + self.value = value + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> TextChunk { + let object = try TypraRuntime.object(data, typeName: "TextChunk") + var instance = TextChunk() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "text" + } + if let value = object["value"] { + instance.value = try TypraRuntime.string(value, field: "value") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["value"] = self.value + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TextChunk + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "TextChunk"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TextChunk + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "TextChunk"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// A thinking/reasoning content chunk from the LLM response stream. +public struct ThinkingChunk: TypraModel { + public var kind: String = "thinking" + public var value: String = "" + + public init(kind: String = "thinking", value: String = "") { + self.kind = kind + self.value = value + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> ThinkingChunk + { + let object = try TypraRuntime.object(data, typeName: "ThinkingChunk") + var instance = ThinkingChunk() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "thinking" + } + if let value = object["value"] { + instance.value = try TypraRuntime.string(value, field: "value") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["value"] = self.value + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ThinkingChunk + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ThinkingChunk"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ThinkingChunk + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ThinkingChunk"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// A tool call chunk from the LLM response stream. +public struct ToolChunk: TypraModel { + public var kind: String = "tool" + public var toolCall: ToolCall = ToolCall() + + public init(kind: String = "tool", toolCall: ToolCall = ToolCall()) { + self.kind = kind + self.toolCall = toolCall + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> ToolChunk { + let object = try TypraRuntime.object(data, typeName: "ToolChunk") + var instance = ToolChunk() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "tool" + } + if let value = object["toolCall"] { + instance.toolCall = try ToolCall.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["toolCall"] = try self.toolCall.save(context) + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ToolChunk + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "ToolChunk"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ToolChunk + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "ToolChunk"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// Cumulative token usage emitted once after provider content and tool chunks. +public struct UsageChunk: TypraModel { + public var kind: String = "usage" + public var usage: InvocationUsage = InvocationUsage() + + public init(kind: String = "usage", usage: InvocationUsage = InvocationUsage()) { + self.kind = kind + self.usage = usage + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> UsageChunk { + let object = try TypraRuntime.object(data, typeName: "UsageChunk") + var instance = UsageChunk() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "usage" + } + if let value = object["usage"] { + instance.usage = try InvocationUsage.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["usage"] = try self.usage.save(context) + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> UsageChunk + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "UsageChunk"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> UsageChunk + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "UsageChunk"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// An error chunk from the LLM response stream. +public struct ErrorChunk: TypraModel { + public var kind: String = "error" + public var message: String = "" + + public init(kind: String = "error", message: String = "") { + self.kind = kind + self.message = message + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> ErrorChunk { + let object = try TypraRuntime.object(data, typeName: "ErrorChunk") + var instance = ErrorChunk() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "error" + } + if let value = object["message"] { + instance.message = try TypraRuntime.string(value, field: "message") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["message"] = self.message + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ErrorChunk + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "ErrorChunk"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ErrorChunk + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "ErrorChunk"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/thinking_event_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/thinking_event_payload.swift new file mode 100644 index 000000000..b735c1255 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/thinking_event_payload.swift @@ -0,0 +1,52 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "thinking" events — reasoning/chain-of-thought tokens. +public struct ThinkingEventPayload: TypraModel { + public var token: String = "" + + public init(token: String = "") { + self.token = token + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ThinkingEventPayload + { + let object = try TypraRuntime.object(data, typeName: "ThinkingEventPayload") + var instance = ThinkingEventPayload() + if let value = object["token"] { + instance.token = try TypraRuntime.string(value, field: "token") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["token"] = self.token + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ThinkingEventPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ThinkingEventPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ThinkingEventPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ThinkingEventPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/token_event_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/token_event_payload.swift new file mode 100644 index 000000000..07ca765d2 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/token_event_payload.swift @@ -0,0 +1,52 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "token" events — a single text token streamed from the LLM. +public struct TokenEventPayload: TypraModel { + public var token: String = "" + + public init(token: String = "") { + self.token = token + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> TokenEventPayload + { + let object = try TypraRuntime.object(data, typeName: "TokenEventPayload") + var instance = TokenEventPayload() + if let value = object["token"] { + instance.token = try TypraRuntime.string(value, field: "token") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["token"] = self.token + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TokenEventPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "TokenEventPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TokenEventPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "TokenEventPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/tool_call_complete_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/tool_call_complete_payload.swift new file mode 100644 index 000000000..31fa37f86 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/tool_call_complete_payload.swift @@ -0,0 +1,93 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "tool_call_complete" events — a tool dispatch finished. +public struct ToolCallCompletePayload: TypraModel { + public var id: String? = nil + public var name: String = "" + public var success: Bool = false + public var result: ToolResult? = nil + public var durationMs: Double? = nil + public var errorKind: String? = nil + + public init( + id: String? = nil, name: String = "", success: Bool = false, result: ToolResult? = nil, + durationMs: Double? = nil, errorKind: String? = nil + ) { + self.id = id + self.name = name + self.success = success + self.result = result + self.durationMs = durationMs + self.errorKind = errorKind + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ToolCallCompletePayload + { + let object = try TypraRuntime.object(data, typeName: "ToolCallCompletePayload") + var instance = ToolCallCompletePayload() + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["success"] { + instance.success = try TypraRuntime.bool(value, field: "success") + } + if let value = object["result"] { + instance.result = try ToolResult.load(value, context: context) + } + if let value = object["durationMs"] { + instance.durationMs = try TypraRuntime.double(value, field: "durationMs") + } + if let value = object["errorKind"] { + instance.errorKind = try TypraRuntime.string(value, field: "errorKind") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.id { + result["id"] = value + } + result["name"] = self.name + result["success"] = self.success + if let value = self.result { + result["result"] = try value.save(context) + } + if let value = self.durationMs { + result["durationMs"] = value + } + if let value = self.errorKind { + result["errorKind"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ToolCallCompletePayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ToolCallCompletePayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ToolCallCompletePayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ToolCallCompletePayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/tool_call_start_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/tool_call_start_payload.swift new file mode 100644 index 000000000..a9f33b769 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/tool_call_start_payload.swift @@ -0,0 +1,66 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "tool_call_start" events — the LLM has requested a tool call. +public struct ToolCallStartPayload: TypraModel { + public var id: String? = nil + public var name: String = "" + public var arguments: String = "" + + public init(id: String? = nil, name: String = "", arguments: String = "") { + self.id = id + self.name = name + self.arguments = arguments + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ToolCallStartPayload + { + let object = try TypraRuntime.object(data, typeName: "ToolCallStartPayload") + var instance = ToolCallStartPayload() + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["arguments"] { + instance.arguments = try TypraRuntime.string(value, field: "arguments") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.id { + result["id"] = value + } + result["name"] = self.name + result["arguments"] = self.arguments + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ToolCallStartPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ToolCallStartPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ToolCallStartPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ToolCallStartPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/tool_execution_complete_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/tool_execution_complete_payload.swift new file mode 100644 index 000000000..c00356409 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/tool_execution_complete_payload.swift @@ -0,0 +1,128 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "tool_execution_complete" events — a concrete host tool execution finished. +public struct ToolExecutionCompletePayload: TypraModel { + public var requestId: String? = nil + public var toolCallId: String? = nil + public var toolName: String = "" + public var success: Bool = false + public var result: Any? = nil + public var exitCode: Int32? = nil + public var durationMs: Double? = nil + public var errorKind: String? = nil + public var telemetry: [String: Any]? = nil + public var redaction: RedactionMetadata? = nil + + public init( + requestId: String? = nil, toolCallId: String? = nil, toolName: String = "", + success: Bool = false, result: Any? = nil, exitCode: Int32? = nil, durationMs: Double? = nil, + errorKind: String? = nil, telemetry: [String: Any]? = nil, redaction: RedactionMetadata? = nil + ) { + self.requestId = requestId + self.toolCallId = toolCallId + self.toolName = toolName + self.success = success + self.result = result + self.exitCode = exitCode + self.durationMs = durationMs + self.errorKind = errorKind + self.telemetry = telemetry + self.redaction = redaction + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ToolExecutionCompletePayload + { + let object = try TypraRuntime.object(data, typeName: "ToolExecutionCompletePayload") + var instance = ToolExecutionCompletePayload() + if let value = object["requestId"] { + instance.requestId = try TypraRuntime.string(value, field: "requestId") + } + if let value = object["toolCallId"] { + instance.toolCallId = try TypraRuntime.string(value, field: "toolCallId") + } + if let value = object["toolName"] { + instance.toolName = try TypraRuntime.string(value, field: "toolName") + } + if let value = object["success"] { + instance.success = try TypraRuntime.bool(value, field: "success") + } + if let value = object["result"] { + instance.result = value + } + if let value = object["exitCode"] { + instance.exitCode = try TypraRuntime.int32(value, field: "exitCode") + } + if let value = object["durationMs"] { + instance.durationMs = try TypraRuntime.double(value, field: "durationMs") + } + if let value = object["errorKind"] { + instance.errorKind = try TypraRuntime.string(value, field: "errorKind") + } + if let value = object["telemetry"] { + instance.telemetry = try TypraRuntime.dictionary(value, field: "telemetry") + } + if let value = object["redaction"] { + instance.redaction = try RedactionMetadata.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.requestId { + result["requestId"] = value + } + if let value = self.toolCallId { + result["toolCallId"] = value + } + result["toolName"] = self.toolName + result["success"] = self.success + if let value = self.result { + result["result"] = value + } + if let value = self.exitCode { + result["exitCode"] = value + } + if let value = self.durationMs { + result["durationMs"] = value + } + if let value = self.errorKind { + result["errorKind"] = value + } + if let value = self.telemetry { + result["telemetry"] = value + } + if let value = self.redaction { + result["redaction"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ToolExecutionCompletePayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ToolExecutionCompletePayload"), + context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ToolExecutionCompletePayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ToolExecutionCompletePayload"), + context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/tool_execution_start_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/tool_execution_start_payload.swift new file mode 100644 index 000000000..05745c8d3 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/tool_execution_start_payload.swift @@ -0,0 +1,96 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "tool_execution_start" events — the host is about to execute a concrete tool request. This is distinct from "tool_call_start", which records the model requesting a tool. Tool execution events capture the harness-side action after policy and permission checks. +public struct ToolExecutionStartPayload: TypraModel { + public var requestId: String? = nil + public var toolCallId: String? = nil + public var toolName: String = "" + public var arguments: [String: Any]? = nil + public var workingDirectory: String? = nil + public var redaction: RedactionMetadata? = nil + + public init( + requestId: String? = nil, toolCallId: String? = nil, toolName: String = "", + arguments: [String: Any]? = nil, workingDirectory: String? = nil, + redaction: RedactionMetadata? = nil + ) { + self.requestId = requestId + self.toolCallId = toolCallId + self.toolName = toolName + self.arguments = arguments + self.workingDirectory = workingDirectory + self.redaction = redaction + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ToolExecutionStartPayload + { + let object = try TypraRuntime.object(data, typeName: "ToolExecutionStartPayload") + var instance = ToolExecutionStartPayload() + if let value = object["requestId"] { + instance.requestId = try TypraRuntime.string(value, field: "requestId") + } + if let value = object["toolCallId"] { + instance.toolCallId = try TypraRuntime.string(value, field: "toolCallId") + } + if let value = object["toolName"] { + instance.toolName = try TypraRuntime.string(value, field: "toolName") + } + if let value = object["arguments"] { + instance.arguments = try TypraRuntime.dictionary(value, field: "arguments") + } + if let value = object["workingDirectory"] { + instance.workingDirectory = try TypraRuntime.string(value, field: "workingDirectory") + } + if let value = object["redaction"] { + instance.redaction = try RedactionMetadata.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.requestId { + result["requestId"] = value + } + if let value = self.toolCallId { + result["toolCallId"] = value + } + result["toolName"] = self.toolName + if let value = self.arguments { + result["arguments"] = value + } + if let value = self.workingDirectory { + result["workingDirectory"] = value + } + if let value = self.redaction { + result["redaction"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ToolExecutionStartPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ToolExecutionStartPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ToolExecutionStartPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ToolExecutionStartPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/tool_result_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/tool_result_payload.swift new file mode 100644 index 000000000..2e7ade500 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/tool_result_payload.swift @@ -0,0 +1,58 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "tool_result" events — a tool has returned its result. +public struct ToolResultPayload: TypraModel { + public var name: String = "" + public var result: ToolResult = ToolResult() + + public init(name: String = "", result: ToolResult = ToolResult()) { + self.name = name + self.result = result + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ToolResultPayload + { + let object = try TypraRuntime.object(data, typeName: "ToolResultPayload") + var instance = ToolResultPayload() + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["result"] { + instance.result = try ToolResult.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["name"] = self.name + result["result"] = try self.result.save(context) + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ToolResultPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ToolResultPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ToolResultPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ToolResultPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/trajectory_event.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/trajectory_event.swift new file mode 100644 index 000000000..7f811d085 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/trajectory_event.swift @@ -0,0 +1,120 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A compact, replay-oriented record of one harness-side action or observation. +public struct TrajectoryEvent: TypraModel { + public var id: String? = nil + public var sessionId: String? = nil + public var turnId: String? = nil + public var toolCallId: String? = nil + public var turnIndex: Int32? = nil + public var eventType: String = "" + public var data: [String: Any]? = nil + public var createdAt: String? = nil + public var redaction: RedactionMetadata? = nil + + public init( + id: String? = nil, sessionId: String? = nil, turnId: String? = nil, toolCallId: String? = nil, + turnIndex: Int32? = nil, eventType: String = "", data: [String: Any]? = nil, + createdAt: String? = nil, redaction: RedactionMetadata? = nil + ) { + self.id = id + self.sessionId = sessionId + self.turnId = turnId + self.toolCallId = toolCallId + self.turnIndex = turnIndex + self.eventType = eventType + self.data = data + self.createdAt = createdAt + self.redaction = redaction + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> TrajectoryEvent + { + let object = try TypraRuntime.object(data, typeName: "TrajectoryEvent") + var instance = TrajectoryEvent() + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["toolCallId"] { + instance.toolCallId = try TypraRuntime.string(value, field: "toolCallId") + } + if let value = object["turnIndex"] { + instance.turnIndex = try TypraRuntime.int32(value, field: "turnIndex") + } + if let value = object["eventType"] { + instance.eventType = try TypraRuntime.string(value, field: "eventType") + } + if let value = object["data"] { + instance.data = try TypraRuntime.dictionary(value, field: "data") + } + if let value = object["createdAt"] { + instance.createdAt = try TypraRuntime.string(value, field: "createdAt") + } + if let value = object["redaction"] { + instance.redaction = try RedactionMetadata.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.id { + result["id"] = value + } + if let value = self.sessionId { + result["sessionId"] = value + } + if let value = self.turnId { + result["turnId"] = value + } + if let value = self.toolCallId { + result["toolCallId"] = value + } + if let value = self.turnIndex { + result["turnIndex"] = value + } + result["eventType"] = self.eventType + if let value = self.data { + result["data"] = value + } + if let value = self.createdAt { + result["createdAt"] = value + } + if let value = self.redaction { + result["redaction"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TrajectoryEvent + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "TrajectoryEvent"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TrajectoryEvent + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "TrajectoryEvent"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/turn_end_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/turn_end_payload.swift new file mode 100644 index 000000000..5d8897c3a --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/turn_end_payload.swift @@ -0,0 +1,95 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum TurnStatus: String, Codable, CaseIterable { + case success = "success" + case error = "error" + case cancelled = "cancelled" + public static func parse(_ value: String) throws -> TurnStatus { + switch value { + case "success": return .success + case "error": return .error + case "cancelled": return .cancelled + default: throw TypraRuntimeError.invalidEnum(type: "TurnStatus", value: value) + } + } +} + +/// Payload for "turn_end" events — a turn has completed. +public struct TurnEndPayload: TypraModel { + public var iterations: Int32? = nil + public var status: TurnStatus? = nil + public var response: Any? = nil + public var durationMs: Double? = nil + + public init( + iterations: Int32? = nil, status: TurnStatus? = nil, response: Any? = nil, + durationMs: Double? = nil + ) { + self.iterations = iterations + self.status = status + self.response = response + self.durationMs = durationMs + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> TurnEndPayload + { + let object = try TypraRuntime.object(data, typeName: "TurnEndPayload") + var instance = TurnEndPayload() + if let value = object["iterations"] { + instance.iterations = try TypraRuntime.int32(value, field: "iterations") + } + if let value = object["status"] { + instance.status = try TurnStatus.parse(try TypraRuntime.string(value, field: "status")) + } + if let value = object["response"] { + instance.response = value + } + if let value = object["durationMs"] { + instance.durationMs = try TypraRuntime.double(value, field: "durationMs") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.iterations { + result["iterations"] = value + } + if let value = self.status { + result["status"] = value.rawValue + } + if let value = self.response { + result["response"] = value + } + if let value = self.durationMs { + result["durationMs"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TurnEndPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "TurnEndPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TurnEndPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "TurnEndPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/turn_event.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/turn_event.swift new file mode 100644 index 000000000..96d7c4b5b --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/turn_event.swift @@ -0,0 +1,158 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum TurnEventType: String, Codable, CaseIterable { + case turnStart = "turn_start" + case turnEnd = "turn_end" + case llmStart = "llm_start" + case llmComplete = "llm_complete" + case retry = "retry" + case permissionRequested = "permission_requested" + case permissionCompleted = "permission_completed" + case token = "token" + case thinking = "thinking" + case toolCallStart = "tool_call_start" + case toolCallComplete = "tool_call_complete" + case toolExecutionStart = "tool_execution_start" + case toolExecutionComplete = "tool_execution_complete" + case toolResult = "tool_result" + case hookStart = "hook_start" + case hookEnd = "hook_end" + case status = "status" + case messagesUpdated = "messages_updated" + case done = "done" + case error = "error" + case cancelled = "cancelled" + case compactionStart = "compaction_start" + case compactionComplete = "compaction_complete" + case compactionFailed = "compaction_failed" + public static func parse(_ value: String) throws -> TurnEventType { + switch value { + case "turn_start": return .turnStart + case "turn_end": return .turnEnd + case "llm_start": return .llmStart + case "llm_complete": return .llmComplete + case "retry": return .retry + case "permission_requested": return .permissionRequested + case "permission_completed": return .permissionCompleted + case "token": return .token + case "thinking": return .thinking + case "tool_call_start": return .toolCallStart + case "tool_call_complete": return .toolCallComplete + case "tool_execution_start": return .toolExecutionStart + case "tool_execution_complete": return .toolExecutionComplete + case "tool_result": return .toolResult + case "hook_start": return .hookStart + case "hook_end": return .hookEnd + case "status": return .status + case "messages_updated": return .messagesUpdated + case "done": return .done + case "error": return .error + case "cancelled": return .cancelled + case "compaction_start": return .compactionStart + case "compaction_complete": return .compactionComplete + case "compaction_failed": return .compactionFailed + default: throw TypraRuntimeError.invalidEnum(type: "TurnEventType", value: value) + } + } +} + +/// A canonical event envelope emitted by the turn harness. The payload is kept JSON-shaped so runtimes can load all events even when newer payload types are added; event-specific typed payload models below define the canonical shapes. +public struct TurnEvent: TypraModel { + public var id: String = "" + public var type: TurnEventType = (try! TurnEventType.parse("turn_start")) + public var timestamp: String = "" + public var turnId: String? = nil + public var iteration: Int32? = nil + public var parentId: String? = nil + public var spanId: String? = nil + public var payload: [String: Any] = [:] + + public init( + id: String = "", type: TurnEventType = (try! TurnEventType.parse("turn_start")), + timestamp: String = "", turnId: String? = nil, iteration: Int32? = nil, parentId: String? = nil, + spanId: String? = nil, payload: [String: Any] = [:] + ) { + self.id = id + self.type = type + self.timestamp = timestamp + self.turnId = turnId + self.iteration = iteration + self.parentId = parentId + self.spanId = spanId + self.payload = payload + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> TurnEvent { + let object = try TypraRuntime.object(data, typeName: "TurnEvent") + var instance = TurnEvent() + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["type"] { + instance.type = try TurnEventType.parse(try TypraRuntime.string(value, field: "type")) + } + if let value = object["timestamp"] { + instance.timestamp = try TypraRuntime.string(value, field: "timestamp") + } + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["iteration"] { + instance.iteration = try TypraRuntime.int32(value, field: "iteration") + } + if let value = object["parentId"] { + instance.parentId = try TypraRuntime.string(value, field: "parentId") + } + if let value = object["spanId"] { + instance.spanId = try TypraRuntime.string(value, field: "spanId") + } + if let value = object["payload"] { + instance.payload = try TypraRuntime.dictionary(value, field: "payload") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["id"] = self.id + result["type"] = self.type.rawValue + result["timestamp"] = self.timestamp + if let value = self.turnId { + result["turnId"] = value + } + if let value = self.iteration { + result["iteration"] = value + } + if let value = self.parentId { + result["parentId"] = value + } + if let value = self.spanId { + result["spanId"] = value + } + result["payload"] = self.payload + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TurnEvent + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "TurnEvent"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TurnEvent + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "TurnEvent"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/turn_start_payload.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/turn_start_payload.swift new file mode 100644 index 000000000..bfd23bf38 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/turn_start_payload.swift @@ -0,0 +1,70 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Payload for "turn_start" events — a turn is beginning. +public struct TurnStartPayload: TypraModel { + public var agent: String? = nil + public var inputs: [String: Any]? = nil + public var maxIterations: Int32? = nil + + public init(agent: String? = nil, inputs: [String: Any]? = nil, maxIterations: Int32? = nil) { + self.agent = agent + self.inputs = inputs + self.maxIterations = maxIterations + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> TurnStartPayload + { + let object = try TypraRuntime.object(data, typeName: "TurnStartPayload") + var instance = TurnStartPayload() + if let value = object["agent"] { + instance.agent = try TypraRuntime.string(value, field: "agent") + } + if let value = object["inputs"] { + instance.inputs = try TypraRuntime.dictionary(value, field: "inputs") + } + if let value = object["maxIterations"] { + instance.maxIterations = try TypraRuntime.int32(value, field: "maxIterations") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.agent { + result["agent"] = value + } + if let value = self.inputs { + result["inputs"] = value + } + if let value = self.maxIterations { + result["maxIterations"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TurnStartPayload + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "TurnStartPayload"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TurnStartPayload + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "TurnStartPayload"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/turn_summary.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/turn_summary.swift new file mode 100644 index 000000000..9f2f050f0 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/turn_summary.swift @@ -0,0 +1,104 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Summary statistics for a completed turn trace. +public struct TurnSummary: TypraModel { + public var turnId: String = "" + public var status: String = "" + public var iterations: Int32 = 0 + public var llmCalls: Int32? = nil + public var toolCalls: Int32? = nil + public var retries: Int32? = nil + public var usage: TokenUsage? = nil + public var durationMs: Double? = nil + + public init( + turnId: String = "", status: String = "", iterations: Int32 = 0, llmCalls: Int32? = nil, + toolCalls: Int32? = nil, retries: Int32? = nil, usage: TokenUsage? = nil, + durationMs: Double? = nil + ) { + self.turnId = turnId + self.status = status + self.iterations = iterations + self.llmCalls = llmCalls + self.toolCalls = toolCalls + self.retries = retries + self.usage = usage + self.durationMs = durationMs + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> TurnSummary { + let object = try TypraRuntime.object(data, typeName: "TurnSummary") + var instance = TurnSummary() + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["status"] { + instance.status = try TypraRuntime.string(value, field: "status") + } + if let value = object["iterations"] { + instance.iterations = try TypraRuntime.int32(value, field: "iterations") + } + if let value = object["llmCalls"] { + instance.llmCalls = try TypraRuntime.int32(value, field: "llmCalls") + } + if let value = object["toolCalls"] { + instance.toolCalls = try TypraRuntime.int32(value, field: "toolCalls") + } + if let value = object["retries"] { + instance.retries = try TypraRuntime.int32(value, field: "retries") + } + if let value = object["usage"] { + instance.usage = try TokenUsage.load(value, context: context) + } + if let value = object["durationMs"] { + instance.durationMs = try TypraRuntime.double(value, field: "durationMs") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["turnId"] = self.turnId + result["status"] = self.status + result["iterations"] = self.iterations + if let value = self.llmCalls { + result["llmCalls"] = value + } + if let value = self.toolCalls { + result["toolCalls"] = value + } + if let value = self.retries { + result["retries"] = value + } + if let value = self.usage { + result["usage"] = try value.save(context) + } + if let value = self.durationMs { + result["durationMs"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TurnSummary + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "TurnSummary"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TurnSummary + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "TurnSummary"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/events/turn_trace.swift b/runtime/swift/prompty-model/Sources/PromptyModel/events/turn_trace.swift new file mode 100644 index 000000000..9ccbdb4ba --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/events/turn_trace.swift @@ -0,0 +1,85 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Portable JSONL/replay container for a recorded turn harness run. +public struct TurnTrace: TypraModel { + public var version: String = "1" + public var runtime: String? = nil + public var promptyVersion: String? = nil + public var events: [TurnEvent] = [] + public var summary: TurnSummary? = nil + + public init( + version: String = "1", runtime: String? = nil, promptyVersion: String? = nil, + events: [TurnEvent] = [], summary: TurnSummary? = nil + ) { + self.version = version + self.runtime = runtime + self.promptyVersion = promptyVersion + self.events = events + self.summary = summary + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> TurnTrace { + let object = try TypraRuntime.object(data, typeName: "TurnTrace") + var instance = TurnTrace() + if let value = object["version"] { + instance.version = try TypraRuntime.string(value, field: "version") + } else { + instance.version = "1" + } + if let value = object["runtime"] { + instance.runtime = try TypraRuntime.string(value, field: "runtime") + } + if let value = object["promptyVersion"] { + instance.promptyVersion = try TypraRuntime.string(value, field: "promptyVersion") + } + if let value = object["events"] { + instance.events = try TypraRuntime.array(value, field: "events").map { + try TurnEvent.load($0, context: context) + } + } + if let value = object["summary"] { + instance.summary = try TurnSummary.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["version"] = self.version + if let value = self.runtime { + result["runtime"] = value + } + if let value = self.promptyVersion { + result["promptyVersion"] = value + } + result["events"] = try self.events.map { try $0.save(context) } + if let value = self.summary { + result["summary"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TurnTrace + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "TurnTrace"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TurnTrace + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "TurnTrace"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/memory/memory_entry.swift b/runtime/swift/prompty-model/Sources/PromptyModel/memory/memory_entry.swift new file mode 100644 index 000000000..7a85cef9b --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/memory/memory_entry.swift @@ -0,0 +1,94 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum MemoryCategory: String, Codable, CaseIterable { + case core = "core" + case archival = "archival" + case insight = "insight" + public static func parse(_ value: String) throws -> MemoryCategory { + switch value { + case "core": return .core + case "archival": return .archival + case "insight": return .insight + default: throw TypraRuntimeError.invalidEnum(type: "MemoryCategory", value: value) + } + } +} + +/// A single agent memory — the canonical, host-neutral unit of agent memory. `content` is the memory text, `category` places it in a general tier, `createdAt` records when the memory was formed (intrinsic, portable data), and `tags` are general labels used for keyword recall, grouping, and core deduplication. Host-specific associations (for example a session association) are expressed through the general `tags` field by convention — e.g. a `session:{id}` tag — never as a canonical field. A host needing per-entry bookkeeping, a stable id, or a stored embedding vector layers it in host storage; those are not canonical fields. +public struct MemoryEntry: TypraModel { + public var content: String = "" + public var category: MemoryCategory = (try! MemoryCategory.parse("core")) + public var createdAt: String? = nil + public var tags: [String]? = nil + + public init( + content: String = "", category: MemoryCategory = (try! MemoryCategory.parse("core")), + createdAt: String? = nil, tags: [String]? = nil + ) { + self.content = content + self.category = category + self.createdAt = createdAt + self.tags = tags + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> MemoryEntry { + let object = try TypraRuntime.object(data, typeName: "MemoryEntry") + var instance = MemoryEntry() + if let value = object["content"] { + instance.content = try TypraRuntime.string(value, field: "content") + } else { + instance.content = "" + } + if let value = object["category"] { + instance.category = try MemoryCategory.parse( + try TypraRuntime.string(value, field: "category")) + } else { + instance.category = (try! MemoryCategory.parse("core")) + } + if let value = object["createdAt"] { + instance.createdAt = try TypraRuntime.string(value, field: "createdAt") + } + if let value = object["tags"] { + instance.tags = try TypraRuntime.array(value, field: "tags").map { + try TypraRuntime.string($0, field: "tags") + } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["content"] = self.content + result["category"] = self.category.rawValue + if let value = self.createdAt { + result["createdAt"] = value + } + if let value = self.tags { + result["tags"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> MemoryEntry + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "MemoryEntry"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> MemoryEntry + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "MemoryEntry"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/memory/memory_store.swift b/runtime/swift/prompty-model/Sources/PromptyModel/memory/memory_store.swift new file mode 100644 index 000000000..3614a71e3 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/memory/memory_store.swift @@ -0,0 +1,50 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A whole-store snapshot of agent memory. The canonical persisted shape a host loads and saves as one unit. A host backend implements only load/save of this snapshot; the engine owns the deterministic recall, formatting, tiered injection, eviction, and entry-mutation logic on top of it. +public struct MemoryStore: TypraModel { + public var entries: [MemoryEntry] = [] + + public init(entries: [MemoryEntry] = []) { + self.entries = entries + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> MemoryStore { + let object = try TypraRuntime.object(data, typeName: "MemoryStore") + var instance = MemoryStore() + if let value = object["entries"] { + instance.entries = try TypraRuntime.array(value, field: "entries").map { + try MemoryEntry.load($0, context: context) + } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["entries"] = try self.entries.map { try $0.save(context) } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> MemoryStore + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "MemoryStore"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> MemoryStore + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "MemoryStore"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/model/ai_resource_info.swift b/runtime/swift/prompty-model/Sources/PromptyModel/model/ai_resource_info.swift new file mode 100644 index 000000000..70c8336ed --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/model/ai_resource_info.swift @@ -0,0 +1,130 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// An AI service resource that can host models or projects. +public struct AiResourceInfo: TypraModel { + public var name: String = "" + public var kind: String = "" + public var endpoint: String = "" + public var location: String = "" + public var resourceGroup: String = "" + public var serviceUrl: String? = nil + + public init( + name: String = "", kind: String = "", endpoint: String = "", location: String = "", + resourceGroup: String = "", serviceUrl: String? = nil + ) { + self.name = name + self.kind = kind + self.endpoint = endpoint + self.location = location + self.resourceGroup = resourceGroup + self.serviceUrl = serviceUrl + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> AiResourceInfo + { + let object = try TypraRuntime.object(data, typeName: "AiResourceInfo") + var instance = AiResourceInfo() + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } + if let value = object["endpoint"] { + instance.endpoint = try TypraRuntime.string(value, field: "endpoint") + } + if let value = object["location"] { + instance.location = try TypraRuntime.string(value, field: "location") + } + if let value = object["resourceGroup"] { + instance.resourceGroup = try TypraRuntime.string(value, field: "resourceGroup") + } + if let value = object["serviceUrl"] { + instance.serviceUrl = try TypraRuntime.string(value, field: "serviceUrl") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["name"] = self.name + result["kind"] = self.kind + result["endpoint"] = self.endpoint + result["location"] = self.location + result["resourceGroup"] = self.resourceGroup + if let value = self.serviceUrl { + result["serviceUrl"] = value + } + return result + } + + public func toWire(_ provider: String, context: SaveContext = SaveContext()) throws -> [String: + Any] + { + var result: [String: Any] = [:] + let wireNameName: String + switch provider { + case "foundry": wireNameName = "name" + default: wireNameName = "name" + } + result[wireNameName] = self.name + let wireNameKind: String + switch provider { + case "foundry": wireNameKind = "kind" + default: wireNameKind = "kind" + } + result[wireNameKind] = self.kind + let wireNameEndpoint: String + switch provider { + case "foundry": wireNameEndpoint = "endpoint" + default: wireNameEndpoint = "endpoint" + } + result[wireNameEndpoint] = self.endpoint + let wireNameLocation: String + switch provider { + case "foundry": wireNameLocation = "location" + default: wireNameLocation = "location" + } + result[wireNameLocation] = self.location + let wireNameResourceGroup: String + switch provider { + case "foundry": wireNameResourceGroup = "resource_group" + default: wireNameResourceGroup = "resourceGroup" + } + result[wireNameResourceGroup] = self.resourceGroup + let wireNameServiceUrl: String + switch provider { + case "foundry": wireNameServiceUrl = "foundry_url" + default: wireNameServiceUrl = "serviceUrl" + } + if let value = self.serviceUrl { result[wireNameServiceUrl] = value } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> AiResourceInfo + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "AiResourceInfo"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> AiResourceInfo + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "AiResourceInfo"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/model/invocation_usage.swift b/runtime/swift/prompty-model/Sources/PromptyModel/model/invocation_usage.swift new file mode 100644 index 000000000..2ab7fa333 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/model/invocation_usage.swift @@ -0,0 +1,91 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Complete cumulative token usage for one completed model invocation. Providers emit this value at most once per invocation. `totalTokens` uses the provider total when available; otherwise the provider adapter computes it as `inputTokens + outputTokens`. +public struct InvocationUsage: TypraModel { + public var inputTokens: Int64 = 0 + public var outputTokens: Int64 = 0 + public var totalTokens: Int64 = 0 + + public init(inputTokens: Int64 = 0, outputTokens: Int64 = 0, totalTokens: Int64 = 0) { + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> InvocationUsage + { + let object = try TypraRuntime.object(data, typeName: "InvocationUsage") + var instance = InvocationUsage() + if let value = object["inputTokens"] { + instance.inputTokens = try TypraRuntime.int64(value, field: "inputTokens") + } + if let value = object["outputTokens"] { + instance.outputTokens = try TypraRuntime.int64(value, field: "outputTokens") + } + if let value = object["totalTokens"] { + instance.totalTokens = try TypraRuntime.int64(value, field: "totalTokens") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["inputTokens"] = self.inputTokens + result["outputTokens"] = self.outputTokens + result["totalTokens"] = self.totalTokens + return result + } + + public func toWire(_ provider: String, context: SaveContext = SaveContext()) throws -> [String: + Any] + { + var result: [String: Any] = [:] + let wireNameInputTokens: String + switch provider { + case "openai": wireNameInputTokens = "prompt_tokens" + case "anthropic": wireNameInputTokens = "input_tokens" + default: wireNameInputTokens = "inputTokens" + } + result[wireNameInputTokens] = self.inputTokens + let wireNameOutputTokens: String + switch provider { + case "openai": wireNameOutputTokens = "completion_tokens" + case "anthropic": wireNameOutputTokens = "output_tokens" + default: wireNameOutputTokens = "outputTokens" + } + result[wireNameOutputTokens] = self.outputTokens + let wireNameTotalTokens: String + switch provider { + case "openai": wireNameTotalTokens = "total_tokens" + default: wireNameTotalTokens = "totalTokens" + } + result[wireNameTotalTokens] = self.totalTokens + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> InvocationUsage + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "InvocationUsage"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> InvocationUsage + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "InvocationUsage"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/model/model.swift b/runtime/swift/prompty-model/Sources/PromptyModel/model/model.swift new file mode 100644 index 000000000..d5b89ac59 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/model/model.swift @@ -0,0 +1,106 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public struct ApiType: RawRepresentable, Equatable, Hashable, Codable { + public let rawValue: String + public init(rawValue: String) { self.rawValue = rawValue } + public static let chat = ApiType(rawValue: "chat") + public static let embedding = ApiType(rawValue: "embedding") + public static let image = ApiType(rawValue: "image") + public static let responses = ApiType(rawValue: "responses") + public static func parse(_ value: String) throws -> ApiType { + switch value { + case "chat": return .chat + case "embedding": return .embedding + case "image": return .image + case "responses": return .responses + default: return ApiType(rawValue: value) + } + } +} + +/// Model for defining the structure and behavior of AI agents. This model includes properties for specifying the model's provider, connection details, and various options. It allows for flexible configuration of AI models to suit different use cases and requirements. +public struct Model: TypraModel { + public var id: String = "" + public var provider: String? = nil + public var apiType: ApiType? = nil + public var connection: Connection? = nil + public var options: ModelOptions? = nil + + public init( + id: String = "", provider: String? = nil, apiType: ApiType? = nil, + connection: Connection? = nil, options: ModelOptions? = nil + ) { + self.id = id + self.provider = provider + self.apiType = apiType + self.connection = connection + self.options = options + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> Model { + if let scalar = data as? String { + var instance = Model() + instance.id = try TypraRuntime.string(scalar, field: "id") + return instance + } + let object = try TypraRuntime.object(data, typeName: "Model") + var instance = Model() + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } else { + instance.id = "" + } + if let value = object["provider"] { + instance.provider = try TypraRuntime.string(value, field: "provider") + } + if let value = object["apiType"] { + instance.apiType = try ApiType.parse(try TypraRuntime.string(value, field: "apiType")) + } + if let value = object["connection"] { + instance.connection = try Connection.load(value, context: context) + } + if let value = object["options"] { + instance.options = try ModelOptions.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["id"] = self.id + if let value = self.provider { + result["provider"] = value + } + if let value = self.apiType { + result["apiType"] = value.rawValue + } + if let value = self.connection { + result["connection"] = try value.save(context) + } + if let value = self.options { + result["options"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws -> Model + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "Model"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws -> Model + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "Model"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/model/model_info.swift b/runtime/swift/prompty-model/Sources/PromptyModel/model/model_info.swift new file mode 100644 index 000000000..a73ff8100 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/model/model_info.swift @@ -0,0 +1,149 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Information about a model available from a provider. Used by provider-level model discovery to report which models are available and their capabilities. Not all providers return all fields — implementations SHOULD populate as many fields as the provider's API supports and MAY enrich sparse results from a built-in lookup table of known models. +public struct ModelInfo: TypraModel { + public var id: String = "" + public var displayName: String? = nil + public var ownedBy: String? = nil + public var contextWindow: Int32? = nil + public var inputModalities: [String]? = nil + public var outputModalities: [String]? = nil + public var additionalProperties: [String: Any]? = nil + + public init( + id: String = "", displayName: String? = nil, ownedBy: String? = nil, + contextWindow: Int32? = nil, inputModalities: [String]? = nil, + outputModalities: [String]? = nil, additionalProperties: [String: Any]? = nil + ) { + self.id = id + self.displayName = displayName + self.ownedBy = ownedBy + self.contextWindow = contextWindow + self.inputModalities = inputModalities + self.outputModalities = outputModalities + self.additionalProperties = additionalProperties + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> ModelInfo { + let object = try TypraRuntime.object(data, typeName: "ModelInfo") + var instance = ModelInfo() + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["displayName"] { + instance.displayName = try TypraRuntime.string(value, field: "displayName") + } + if let value = object["ownedBy"] { + instance.ownedBy = try TypraRuntime.string(value, field: "ownedBy") + } + if let value = object["contextWindow"] { + instance.contextWindow = try TypraRuntime.int32(value, field: "contextWindow") + } + if let value = object["inputModalities"] { + instance.inputModalities = try TypraRuntime.array(value, field: "inputModalities").map { + try TypraRuntime.string($0, field: "inputModalities") + } + } + if let value = object["outputModalities"] { + instance.outputModalities = try TypraRuntime.array(value, field: "outputModalities").map { + try TypraRuntime.string($0, field: "outputModalities") + } + } + if let value = object["additionalProperties"] { + instance.additionalProperties = try TypraRuntime.dictionary( + value, field: "additionalProperties") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["id"] = self.id + if let value = self.displayName { + result["displayName"] = value + } + if let value = self.ownedBy { + result["ownedBy"] = value + } + if let value = self.contextWindow { + result["contextWindow"] = value + } + if let value = self.inputModalities { + result["inputModalities"] = value + } + if let value = self.outputModalities { + result["outputModalities"] = value + } + if let value = self.additionalProperties { + result["additionalProperties"] = value + } + return result + } + + public func toWire(_ provider: String, context: SaveContext = SaveContext()) throws -> [String: + Any] + { + var result: [String: Any] = [:] + let wireNameId: String + switch provider { + case "openai": wireNameId = "id" + case "anthropic": wireNameId = "id" + default: wireNameId = "id" + } + result[wireNameId] = self.id + let wireNameDisplayName: String + switch provider { + case "anthropic": wireNameDisplayName = "display_name" + default: wireNameDisplayName = "displayName" + } + if let value = self.displayName { result[wireNameDisplayName] = value } + let wireNameOwnedBy: String + switch provider { + case "openai": wireNameOwnedBy = "owned_by" + default: wireNameOwnedBy = "ownedBy" + } + if let value = self.ownedBy { result[wireNameOwnedBy] = value } + let wireNameContextWindow: String + switch provider { + case "anthropic": wireNameContextWindow = "context_length" + default: wireNameContextWindow = "contextWindow" + } + if let value = self.contextWindow { result[wireNameContextWindow] = value } + let wireNameInputModalities: String + switch provider { + case "anthropic": wireNameInputModalities = "input_modalities" + default: wireNameInputModalities = "inputModalities" + } + if let value = self.inputModalities { result[wireNameInputModalities] = value } + let wireNameOutputModalities: String + switch provider { + case "anthropic": wireNameOutputModalities = "output_modalities" + default: wireNameOutputModalities = "outputModalities" + } + if let value = self.outputModalities { result[wireNameOutputModalities] = value } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ModelInfo + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "ModelInfo"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ModelInfo + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "ModelInfo"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/model/model_lister.swift b/runtime/swift/prompty-model/Sources/PromptyModel/model/model_lister.swift new file mode 100644 index 000000000..25534487f --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/model/model_lister.swift @@ -0,0 +1,8 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public protocol ModelLister { + func listModels(connection: Any) async throws -> [ModelInfo] +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/model/model_options.swift b/runtime/swift/prompty-model/Sources/PromptyModel/model/model_options.swift new file mode 100644 index 000000000..d7c67c60e --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/model/model_options.swift @@ -0,0 +1,201 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Options for configuring the behavior of the AI model. +public struct ModelOptions: TypraModel { + public var frequencyPenalty: Float? = nil + public var maxOutputTokens: Int32? = nil + public var presencePenalty: Float? = nil + public var seed: Int32? = nil + public var temperature: Float? = nil + public var topK: Int32? = nil + public var topP: Float? = nil + public var stopSequences: [String]? = nil + public var allowMultipleToolCalls: Bool? = nil + public var additionalProperties: [String: Any]? = nil + + public init( + frequencyPenalty: Float? = nil, maxOutputTokens: Int32? = nil, presencePenalty: Float? = nil, + seed: Int32? = nil, temperature: Float? = nil, topK: Int32? = nil, topP: Float? = nil, + stopSequences: [String]? = nil, allowMultipleToolCalls: Bool? = nil, + additionalProperties: [String: Any]? = nil + ) { + self.frequencyPenalty = frequencyPenalty + self.maxOutputTokens = maxOutputTokens + self.presencePenalty = presencePenalty + self.seed = seed + self.temperature = temperature + self.topK = topK + self.topP = topP + self.stopSequences = stopSequences + self.allowMultipleToolCalls = allowMultipleToolCalls + self.additionalProperties = additionalProperties + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> ModelOptions + { + let object = try TypraRuntime.object(data, typeName: "ModelOptions") + var instance = ModelOptions() + if let value = object["frequencyPenalty"] { + instance.frequencyPenalty = try TypraRuntime.float(value, field: "frequencyPenalty") + } + if let value = object["maxOutputTokens"] { + instance.maxOutputTokens = try TypraRuntime.int32(value, field: "maxOutputTokens") + } + if let value = object["presencePenalty"] { + instance.presencePenalty = try TypraRuntime.float(value, field: "presencePenalty") + } + if let value = object["seed"] { + instance.seed = try TypraRuntime.int32(value, field: "seed") + } + if let value = object["temperature"] { + instance.temperature = try TypraRuntime.float(value, field: "temperature") + } + if let value = object["topK"] { + instance.topK = try TypraRuntime.int32(value, field: "topK") + } + if let value = object["topP"] { + instance.topP = try TypraRuntime.float(value, field: "topP") + } + if let value = object["stopSequences"] { + instance.stopSequences = try TypraRuntime.array(value, field: "stopSequences").map { + try TypraRuntime.string($0, field: "stopSequences") + } + } + if let value = object["allowMultipleToolCalls"] { + instance.allowMultipleToolCalls = try TypraRuntime.bool( + value, field: "allowMultipleToolCalls") + } + if let value = object["additionalProperties"] { + instance.additionalProperties = try TypraRuntime.dictionary( + value, field: "additionalProperties") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.frequencyPenalty { + result["frequencyPenalty"] = value + } + if let value = self.maxOutputTokens { + result["maxOutputTokens"] = value + } + if let value = self.presencePenalty { + result["presencePenalty"] = value + } + if let value = self.seed { + result["seed"] = value + } + if let value = self.temperature { + result["temperature"] = value + } + if let value = self.topK { + result["topK"] = value + } + if let value = self.topP { + result["topP"] = value + } + if let value = self.stopSequences { + result["stopSequences"] = value + } + if let value = self.allowMultipleToolCalls { + result["allowMultipleToolCalls"] = value + } + if let value = self.additionalProperties { + result["additionalProperties"] = value + } + return result + } + + public func toWire(_ provider: String, context: SaveContext = SaveContext()) throws -> [String: + Any] + { + var result: [String: Any] = [:] + let wireNameFrequencyPenalty: String + switch provider { + case "openai": wireNameFrequencyPenalty = "frequency_penalty" + default: wireNameFrequencyPenalty = "frequencyPenalty" + } + if let value = self.frequencyPenalty { result[wireNameFrequencyPenalty] = value } + let wireNameMaxOutputTokens: String + switch provider { + case "openai": wireNameMaxOutputTokens = "max_completion_tokens" + case "responses": wireNameMaxOutputTokens = "max_output_tokens" + case "anthropic": wireNameMaxOutputTokens = "max_tokens" + default: wireNameMaxOutputTokens = "maxOutputTokens" + } + if let value = self.maxOutputTokens { result[wireNameMaxOutputTokens] = value } + let wireNamePresencePenalty: String + switch provider { + case "openai": wireNamePresencePenalty = "presence_penalty" + default: wireNamePresencePenalty = "presencePenalty" + } + if let value = self.presencePenalty { result[wireNamePresencePenalty] = value } + let wireNameSeed: String + switch provider { + case "openai": wireNameSeed = "seed" + default: wireNameSeed = "seed" + } + if let value = self.seed { result[wireNameSeed] = value } + let wireNameTemperature: String + switch provider { + case "openai": wireNameTemperature = "temperature" + case "responses": wireNameTemperature = "temperature" + case "anthropic": wireNameTemperature = "temperature" + default: wireNameTemperature = "temperature" + } + if let value = self.temperature { result[wireNameTemperature] = value } + let wireNameTopK: String + switch provider { + case "openai": wireNameTopK = "top_k" + case "anthropic": wireNameTopK = "top_k" + default: wireNameTopK = "topK" + } + if let value = self.topK { result[wireNameTopK] = value } + let wireNameTopP: String + switch provider { + case "openai": wireNameTopP = "top_p" + case "responses": wireNameTopP = "top_p" + case "anthropic": wireNameTopP = "top_p" + default: wireNameTopP = "topP" + } + if let value = self.topP { result[wireNameTopP] = value } + let wireNameStopSequences: String + switch provider { + case "openai": wireNameStopSequences = "stop" + case "anthropic": wireNameStopSequences = "stop_sequences" + default: wireNameStopSequences = "stopSequences" + } + if let value = self.stopSequences { result[wireNameStopSequences] = value } + let wireNameAllowMultipleToolCalls: String + switch provider { + case "openai": wireNameAllowMultipleToolCalls = "parallel_tool_calls" + default: wireNameAllowMultipleToolCalls = "allowMultipleToolCalls" + } + if let value = self.allowMultipleToolCalls { result[wireNameAllowMultipleToolCalls] = value } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ModelOptions + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "ModelOptions"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ModelOptions + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "ModelOptions"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/model/project_info.swift b/runtime/swift/prompty-model/Sources/PromptyModel/model/project_info.swift new file mode 100644 index 000000000..095cf2181 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/model/project_info.swift @@ -0,0 +1,85 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A project hosted by an AI service resource. +public struct ProjectInfo: TypraModel { + public var name: String = "" + public var displayName: String = "" + public var endpoint: String = "" + + public init(name: String = "", displayName: String = "", endpoint: String = "") { + self.name = name + self.displayName = displayName + self.endpoint = endpoint + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> ProjectInfo { + let object = try TypraRuntime.object(data, typeName: "ProjectInfo") + var instance = ProjectInfo() + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["displayName"] { + instance.displayName = try TypraRuntime.string(value, field: "displayName") + } + if let value = object["endpoint"] { + instance.endpoint = try TypraRuntime.string(value, field: "endpoint") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["name"] = self.name + result["displayName"] = self.displayName + result["endpoint"] = self.endpoint + return result + } + + public func toWire(_ provider: String, context: SaveContext = SaveContext()) throws -> [String: + Any] + { + var result: [String: Any] = [:] + let wireNameName: String + switch provider { + case "foundry": wireNameName = "name" + default: wireNameName = "name" + } + result[wireNameName] = self.name + let wireNameDisplayName: String + switch provider { + case "foundry": wireNameDisplayName = "display_name" + default: wireNameDisplayName = "displayName" + } + result[wireNameDisplayName] = self.displayName + let wireNameEndpoint: String + switch provider { + case "foundry": wireNameEndpoint = "endpoint" + default: wireNameEndpoint = "endpoint" + } + result[wireNameEndpoint] = self.endpoint + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ProjectInfo + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "ProjectInfo"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ProjectInfo + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "ProjectInfo"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/model/subscription_info.swift b/runtime/swift/prompty-model/Sources/PromptyModel/model/subscription_info.swift new file mode 100644 index 000000000..c6e4fce00 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/model/subscription_info.swift @@ -0,0 +1,89 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A cloud subscription or account boundary available to the authenticated user. +public struct SubscriptionInfo: TypraModel { + public var subscriptionId: String = "" + public var displayName: String = "" + public var state: String = "" + + public init(subscriptionId: String = "", displayName: String = "", state: String = "") { + self.subscriptionId = subscriptionId + self.displayName = displayName + self.state = state + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> SubscriptionInfo + { + let object = try TypraRuntime.object(data, typeName: "SubscriptionInfo") + var instance = SubscriptionInfo() + if let value = object["subscriptionId"] { + instance.subscriptionId = try TypraRuntime.string(value, field: "subscriptionId") + } + if let value = object["displayName"] { + instance.displayName = try TypraRuntime.string(value, field: "displayName") + } + if let value = object["state"] { + instance.state = try TypraRuntime.string(value, field: "state") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["subscriptionId"] = self.subscriptionId + result["displayName"] = self.displayName + result["state"] = self.state + return result + } + + public func toWire(_ provider: String, context: SaveContext = SaveContext()) throws -> [String: + Any] + { + var result: [String: Any] = [:] + let wireNameSubscriptionId: String + switch provider { + case "foundry": wireNameSubscriptionId = "subscription_id" + default: wireNameSubscriptionId = "subscriptionId" + } + result[wireNameSubscriptionId] = self.subscriptionId + let wireNameDisplayName: String + switch provider { + case "foundry": wireNameDisplayName = "display_name" + default: wireNameDisplayName = "displayName" + } + result[wireNameDisplayName] = self.displayName + let wireNameState: String + switch provider { + case "foundry": wireNameState = "state" + default: wireNameState = "state" + } + result[wireNameState] = self.state + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> SubscriptionInfo + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "SubscriptionInfo"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> SubscriptionInfo + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "SubscriptionInfo"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/model/token_usage.swift b/runtime/swift/prompty-model/Sources/PromptyModel/model/token_usage.swift new file mode 100644 index 000000000..d9cda3fe7 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/model/token_usage.swift @@ -0,0 +1,94 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Tracks token consumption for a single LLM call. Provider-specific field names (e.g., OpenAI's `prompt_tokens` vs Anthropic's `input_tokens`) are mapped via `knownAs` augments in the wire directory. +public struct TokenUsage: TypraModel { + public var promptTokens: Int32? = nil + public var completionTokens: Int32? = nil + public var totalTokens: Int32? = nil + + public init(promptTokens: Int32? = nil, completionTokens: Int32? = nil, totalTokens: Int32? = nil) + { + self.promptTokens = promptTokens + self.completionTokens = completionTokens + self.totalTokens = totalTokens + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> TokenUsage { + let object = try TypraRuntime.object(data, typeName: "TokenUsage") + var instance = TokenUsage() + if let value = object["promptTokens"] { + instance.promptTokens = try TypraRuntime.int32(value, field: "promptTokens") + } + if let value = object["completionTokens"] { + instance.completionTokens = try TypraRuntime.int32(value, field: "completionTokens") + } + if let value = object["totalTokens"] { + instance.totalTokens = try TypraRuntime.int32(value, field: "totalTokens") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.promptTokens { + result["promptTokens"] = value + } + if let value = self.completionTokens { + result["completionTokens"] = value + } + if let value = self.totalTokens { + result["totalTokens"] = value + } + return result + } + + public func toWire(_ provider: String, context: SaveContext = SaveContext()) throws -> [String: + Any] + { + var result: [String: Any] = [:] + let wireNamePromptTokens: String + switch provider { + case "openai": wireNamePromptTokens = "prompt_tokens" + case "anthropic": wireNamePromptTokens = "input_tokens" + default: wireNamePromptTokens = "promptTokens" + } + if let value = self.promptTokens { result[wireNamePromptTokens] = value } + let wireNameCompletionTokens: String + switch provider { + case "openai": wireNameCompletionTokens = "completion_tokens" + case "anthropic": wireNameCompletionTokens = "output_tokens" + default: wireNameCompletionTokens = "completionTokens" + } + if let value = self.completionTokens { result[wireNameCompletionTokens] = value } + let wireNameTotalTokens: String + switch provider { + case "openai": wireNameTotalTokens = "total_tokens" + default: wireNameTotalTokens = "totalTokens" + } + if let value = self.totalTokens { result[wireNameTotalTokens] = value } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TokenUsage + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "TokenUsage"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TokenUsage + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "TokenUsage"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/checkpoint_store.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/checkpoint_store.swift new file mode 100644 index 000000000..f06cc2261 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/checkpoint_store.swift @@ -0,0 +1,10 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public protocol CheckpointStore { + func save(checkpoint: Checkpoint) async throws -> Checkpoint + func load(sessionId: String, checkpointId: String) async throws -> Checkpoint? + func listCheckpoints(sessionId: String) async throws -> [Checkpoint] +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/compaction_config.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/compaction_config.swift new file mode 100644 index 000000000..249cd64d5 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/compaction_config.swift @@ -0,0 +1,70 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Configuration for context window compaction. When the message history exceeds the context budget, the compaction strategy is applied to reduce the message list while preserving essential information. +public struct CompactionConfig: TypraModel { + public var strategy: String? = nil + public var budget: Int32? = nil + public var options: [String: Any]? = nil + + public init(strategy: String? = nil, budget: Int32? = nil, options: [String: Any]? = nil) { + self.strategy = strategy + self.budget = budget + self.options = options + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> CompactionConfig + { + let object = try TypraRuntime.object(data, typeName: "CompactionConfig") + var instance = CompactionConfig() + if let value = object["strategy"] { + instance.strategy = try TypraRuntime.string(value, field: "strategy") + } + if let value = object["budget"] { + instance.budget = try TypraRuntime.int32(value, field: "budget") + } + if let value = object["options"] { + instance.options = try TypraRuntime.dictionary(value, field: "options") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.strategy { + result["strategy"] = value + } + if let value = self.budget { + result["budget"] = value + } + if let value = self.options { + result["options"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> CompactionConfig + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "CompactionConfig"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> CompactionConfig + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "CompactionConfig"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/context_candidate.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/context_candidate.swift new file mode 100644 index 000000000..8de9186e3 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/context_candidate.swift @@ -0,0 +1,76 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A context contribution before filtering, ranking, and packing. +public struct ContextCandidate: TypraModel { + public var id: String = "" + public var source: String = "" + public var messages: [Message] = [] + public var metadata: [String: Any]? = nil + + public init( + id: String = "", source: String = "", messages: [Message] = [], metadata: [String: Any]? = nil + ) { + self.id = id + self.source = source + self.messages = messages + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ContextCandidate + { + let object = try TypraRuntime.object(data, typeName: "ContextCandidate") + var instance = ContextCandidate() + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["source"] { + instance.source = try TypraRuntime.string(value, field: "source") + } + if let value = object["messages"] { + instance.messages = try TypraRuntime.array(value, field: "messages").map { + try Message.load($0, context: context) + } + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["id"] = self.id + result["source"] = self.source + result["messages"] = try self.messages.map { try $0.save(context) } + if let value = self.metadata { + result["metadata"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ContextCandidate + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ContextCandidate"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ContextCandidate + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ContextCandidate"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/context_request.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/context_request.swift new file mode 100644 index 000000000..2ecc86632 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/context_request.swift @@ -0,0 +1,104 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Planning input handed to the context pipeline before one model invocation. +public struct ContextRequest: TypraModel { + public var sessionId: String = "" + public var turnId: String = "" + public var invocationId: String = "" + public var iteration: Int32 = 0 + public var messages: [Message] = [] + public var stablePrefixMessages: Int32 = 0 + public var contextState: InvocationContextState = InvocationContextState() + public var inputs: Any? = nil + + public init( + sessionId: String = "", turnId: String = "", invocationId: String = "", iteration: Int32 = 0, + messages: [Message] = [], stablePrefixMessages: Int32 = 0, + contextState: InvocationContextState = InvocationContextState(), inputs: Any? = nil + ) { + self.sessionId = sessionId + self.turnId = turnId + self.invocationId = invocationId + self.iteration = iteration + self.messages = messages + self.stablePrefixMessages = stablePrefixMessages + self.contextState = contextState + self.inputs = inputs + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ContextRequest + { + let object = try TypraRuntime.object(data, typeName: "ContextRequest") + var instance = ContextRequest() + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["invocationId"] { + instance.invocationId = try TypraRuntime.string(value, field: "invocationId") + } + if let value = object["iteration"] { + instance.iteration = try TypraRuntime.int32(value, field: "iteration") + } + if let value = object["messages"] { + instance.messages = try TypraRuntime.array(value, field: "messages").map { + try Message.load($0, context: context) + } + } + if let value = object["stablePrefixMessages"] { + instance.stablePrefixMessages = try TypraRuntime.int32(value, field: "stablePrefixMessages") + } else { + instance.stablePrefixMessages = 0 + } + if let value = object["contextState"] { + instance.contextState = try InvocationContextState.load(value, context: context) + } + if let value = object["inputs"] { + instance.inputs = value + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["sessionId"] = self.sessionId + result["turnId"] = self.turnId + result["invocationId"] = self.invocationId + result["iteration"] = self.iteration + result["messages"] = try self.messages.map { try $0.save(context) } + result["stablePrefixMessages"] = self.stablePrefixMessages + result["contextState"] = try self.contextState.save(context) + if let value = self.inputs { + result["inputs"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ContextRequest + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ContextRequest"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ContextRequest + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ContextRequest"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/delegated_state_reference.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/delegated_state_reference.swift new file mode 100644 index 000000000..e8f1f1b98 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/delegated_state_reference.swift @@ -0,0 +1,74 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A reference to model-visible state retained by a provider. +public struct DelegatedStateReference: TypraModel { + public var provider: String = "" + public var kind: String = "" + public var id: String = "" + public var metadata: [String: Any]? = nil + + public init( + provider: String = "", kind: String = "", id: String = "", metadata: [String: Any]? = nil + ) { + self.provider = provider + self.kind = kind + self.id = id + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> DelegatedStateReference + { + let object = try TypraRuntime.object(data, typeName: "DelegatedStateReference") + var instance = DelegatedStateReference() + if let value = object["provider"] { + instance.provider = try TypraRuntime.string(value, field: "provider") + } + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["provider"] = self.provider + result["kind"] = self.kind + result["id"] = self.id + if let value = self.metadata { + result["metadata"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> DelegatedStateReference + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "DelegatedStateReference"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> DelegatedStateReference + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "DelegatedStateReference"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/engine_checkpoint.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/engine_checkpoint.swift new file mode 100644 index 000000000..82cc3f460 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/engine_checkpoint.swift @@ -0,0 +1,240 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Portable durable checkpoint emitted after a committed model/tool round. A resumed run rebuilt from a checkpoint MUST NOT duplicate a model or tool effect that the checkpoint already records as committed. Run identity is carried so delegated runs checkpoint independently under their own run. +public struct EngineCheckpoint: TypraModel { + public var id: String = "" + public var sessionId: String = "" + public var turnId: String = "" + public var runId: String = "" + public var parentRunId: String? = nil + public var delegationDepth: Int32 = 0 + public var iteration: Int32 = 0 + public var lastSequence: Int64 = 0 + public var messages: [Message] = [] + public var stablePrefixMessages: Int32 = 0 + public var inputs: Any? = nil + public var activeInvocationId: String? = nil + public var pendingToolRequests: [ModelToolRequest]? = nil + public var completedToolResults: [ModelToolResult]? = nil + public var completedModelIterations: Int32 = 0 + public var reconciliationRequired: Bool = false + public var modelReconciliation: ModelReconciliationState? = nil + public var pendingOutput: Any? = nil + public var finalOutputReady: Bool = false + public var pendingModelResponse: ModelInvocationResponse? = nil + public var resumeSameIteration: Bool = false + public var policyAppliedForIteration: Bool = false + public var contextState: InvocationContextState = InvocationContextState() + public var metadata: [String: Any]? = nil + + public init( + id: String = "", sessionId: String = "", turnId: String = "", runId: String = "", + parentRunId: String? = nil, delegationDepth: Int32 = 0, iteration: Int32 = 0, + lastSequence: Int64 = 0, messages: [Message] = [], stablePrefixMessages: Int32 = 0, + inputs: Any? = nil, activeInvocationId: String? = nil, + pendingToolRequests: [ModelToolRequest]? = nil, completedToolResults: [ModelToolResult]? = nil, + completedModelIterations: Int32 = 0, reconciliationRequired: Bool = false, + modelReconciliation: ModelReconciliationState? = nil, pendingOutput: Any? = nil, + finalOutputReady: Bool = false, pendingModelResponse: ModelInvocationResponse? = nil, + resumeSameIteration: Bool = false, policyAppliedForIteration: Bool = false, + contextState: InvocationContextState = InvocationContextState(), metadata: [String: Any]? = nil + ) { + self.id = id + self.sessionId = sessionId + self.turnId = turnId + self.runId = runId + self.parentRunId = parentRunId + self.delegationDepth = delegationDepth + self.iteration = iteration + self.lastSequence = lastSequence + self.messages = messages + self.stablePrefixMessages = stablePrefixMessages + self.inputs = inputs + self.activeInvocationId = activeInvocationId + self.pendingToolRequests = pendingToolRequests + self.completedToolResults = completedToolResults + self.completedModelIterations = completedModelIterations + self.reconciliationRequired = reconciliationRequired + self.modelReconciliation = modelReconciliation + self.pendingOutput = pendingOutput + self.finalOutputReady = finalOutputReady + self.pendingModelResponse = pendingModelResponse + self.resumeSameIteration = resumeSameIteration + self.policyAppliedForIteration = policyAppliedForIteration + self.contextState = contextState + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> EngineCheckpoint + { + let object = try TypraRuntime.object(data, typeName: "EngineCheckpoint") + var instance = EngineCheckpoint() + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["runId"] { + instance.runId = try TypraRuntime.string(value, field: "runId") + } + if let value = object["parentRunId"] { + instance.parentRunId = try TypraRuntime.string(value, field: "parentRunId") + } + if let value = object["delegationDepth"] { + instance.delegationDepth = try TypraRuntime.int32(value, field: "delegationDepth") + } else { + instance.delegationDepth = 0 + } + if let value = object["iteration"] { + instance.iteration = try TypraRuntime.int32(value, field: "iteration") + } + if let value = object["lastSequence"] { + instance.lastSequence = try TypraRuntime.int64(value, field: "lastSequence") + } + if let value = object["messages"] { + instance.messages = try TypraRuntime.array(value, field: "messages").map { + try Message.load($0, context: context) + } + } + if let value = object["stablePrefixMessages"] { + instance.stablePrefixMessages = try TypraRuntime.int32(value, field: "stablePrefixMessages") + } else { + instance.stablePrefixMessages = 0 + } + if let value = object["inputs"] { + instance.inputs = value + } + if let value = object["activeInvocationId"] { + instance.activeInvocationId = try TypraRuntime.string(value, field: "activeInvocationId") + } + if let value = object["pendingToolRequests"] { + instance.pendingToolRequests = try TypraRuntime.array(value, field: "pendingToolRequests").map + { try ModelToolRequest.load($0, context: context) } + } + if let value = object["completedToolResults"] { + instance.completedToolResults = try TypraRuntime.array(value, field: "completedToolResults") + .map { try ModelToolResult.load($0, context: context) } + } + if let value = object["completedModelIterations"] { + instance.completedModelIterations = try TypraRuntime.int32( + value, field: "completedModelIterations") + } else { + instance.completedModelIterations = 0 + } + if let value = object["reconciliationRequired"] { + instance.reconciliationRequired = try TypraRuntime.bool( + value, field: "reconciliationRequired") + } else { + instance.reconciliationRequired = false + } + if let value = object["modelReconciliation"] { + instance.modelReconciliation = try ModelReconciliationState.load(value, context: context) + } + if let value = object["pendingOutput"] { + instance.pendingOutput = value + } + if let value = object["finalOutputReady"] { + instance.finalOutputReady = try TypraRuntime.bool(value, field: "finalOutputReady") + } else { + instance.finalOutputReady = false + } + if let value = object["pendingModelResponse"] { + instance.pendingModelResponse = try ModelInvocationResponse.load(value, context: context) + } + if let value = object["resumeSameIteration"] { + instance.resumeSameIteration = try TypraRuntime.bool(value, field: "resumeSameIteration") + } else { + instance.resumeSameIteration = false + } + if let value = object["policyAppliedForIteration"] { + instance.policyAppliedForIteration = try TypraRuntime.bool( + value, field: "policyAppliedForIteration") + } else { + instance.policyAppliedForIteration = false + } + if let value = object["contextState"] { + instance.contextState = try InvocationContextState.load(value, context: context) + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["id"] = self.id + result["sessionId"] = self.sessionId + result["turnId"] = self.turnId + result["runId"] = self.runId + if let value = self.parentRunId { + result["parentRunId"] = value + } + result["delegationDepth"] = self.delegationDepth + result["iteration"] = self.iteration + result["lastSequence"] = self.lastSequence + result["messages"] = try self.messages.map { try $0.save(context) } + result["stablePrefixMessages"] = self.stablePrefixMessages + if let value = self.inputs { + result["inputs"] = value + } + if let value = self.activeInvocationId { + result["activeInvocationId"] = value + } + if let value = self.pendingToolRequests { + result["pendingToolRequests"] = try value.map { try $0.save(context) } + } + if let value = self.completedToolResults { + result["completedToolResults"] = try value.map { try $0.save(context) } + } + result["completedModelIterations"] = self.completedModelIterations + result["reconciliationRequired"] = self.reconciliationRequired + if let value = self.modelReconciliation { + result["modelReconciliation"] = try value.save(context) + } + if let value = self.pendingOutput { + result["pendingOutput"] = value + } + result["finalOutputReady"] = self.finalOutputReady + if let value = self.pendingModelResponse { + result["pendingModelResponse"] = try value.save(context) + } + result["resumeSameIteration"] = self.resumeSameIteration + result["policyAppliedForIteration"] = self.policyAppliedForIteration + result["contextState"] = try self.contextState.save(context) + if let value = self.metadata { + result["metadata"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> EngineCheckpoint + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "EngineCheckpoint"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> EngineCheckpoint + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "EngineCheckpoint"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/engine_event.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/engine_event.swift new file mode 100644 index 000000000..f16917fa4 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/engine_event.swift @@ -0,0 +1,183 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum EngineEventKind: String, Codable, CaseIterable { + case turnStarted = "turn_started" + case policyApplied = "policy_applied" + case contextPrepared = "context_prepared" + case modelInvocationStarted = "model_invocation_started" + case modelInvocationCompleted = "model_invocation_completed" + case modelInvocationFailed = "model_invocation_failed" + case modelReconciliationRequired = "model_reconciliation_required" + case modelInvocationReconciled = "model_invocation_reconciled" + case permissionRequested = "permission_requested" + case permissionResolved = "permission_resolved" + case toolExecutionStarted = "tool_execution_started" + case toolExecutionCompleted = "tool_execution_completed" + case toolResultCommitted = "tool_result_committed" + case toolResultReconciled = "tool_result_reconciled" + case conversationUpdated = "conversation_updated" + case checkpointCreated = "checkpoint_created" + case turnCommitted = "turn_committed" + case turnCancelled = "turn_cancelled" + case turnFailed = "turn_failed" + case turnReconciliationRequired = "turn_reconciliation_required" + case postCommitStarted = "post_commit_started" + case postCommitCompleted = "post_commit_completed" + case postCommitFailed = "post_commit_failed" + public static func parse(_ value: String) throws -> EngineEventKind { + switch value { + case "turn_started": return .turnStarted + case "policy_applied": return .policyApplied + case "context_prepared": return .contextPrepared + case "model_invocation_started": return .modelInvocationStarted + case "model_invocation_completed": return .modelInvocationCompleted + case "model_invocation_failed": return .modelInvocationFailed + case "model_reconciliation_required": return .modelReconciliationRequired + case "model_invocation_reconciled": return .modelInvocationReconciled + case "permission_requested": return .permissionRequested + case "permission_resolved": return .permissionResolved + case "tool_execution_started": return .toolExecutionStarted + case "tool_execution_completed": return .toolExecutionCompleted + case "tool_result_committed": return .toolResultCommitted + case "tool_result_reconciled": return .toolResultReconciled + case "conversation_updated": return .conversationUpdated + case "checkpoint_created": return .checkpointCreated + case "turn_committed": return .turnCommitted + case "turn_cancelled": return .turnCancelled + case "turn_failed": return .turnFailed + case "turn_reconciliation_required": return .turnReconciliationRequired + case "post_commit_started": return .postCommitStarted + case "post_commit_completed": return .postCommitCompleted + case "post_commit_failed": return .postCommitFailed + default: throw TypraRuntimeError.invalidEnum(type: "EngineEventKind", value: value) + } + } +} + +/// One event in the monotonic semantic event stream of a turn. Events are the durable, replayable record a DurabilityPort persists. Run identity links delegated (nested) engine runs to their parent for cross-run observability. +public struct EngineEvent: TypraModel { + public var sequence: Int64 = 0 + public var id: String = "" + public var timestamp: String = "" + public var sessionId: String = "" + public var turnId: String = "" + public var runId: String = "" + public var parentRunId: String? = nil + public var delegationDepth: Int32 = 0 + public var invocationId: String? = nil + public var iteration: Int32? = nil + public var kind: EngineEventKind = (try! EngineEventKind.parse("turn_started")) + public var payload: Any? = nil + + public init( + sequence: Int64 = 0, id: String = "", timestamp: String = "", sessionId: String = "", + turnId: String = "", runId: String = "", parentRunId: String? = nil, delegationDepth: Int32 = 0, + invocationId: String? = nil, iteration: Int32? = nil, + kind: EngineEventKind = (try! EngineEventKind.parse("turn_started")), payload: Any? = nil + ) { + self.sequence = sequence + self.id = id + self.timestamp = timestamp + self.sessionId = sessionId + self.turnId = turnId + self.runId = runId + self.parentRunId = parentRunId + self.delegationDepth = delegationDepth + self.invocationId = invocationId + self.iteration = iteration + self.kind = kind + self.payload = payload + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> EngineEvent { + let object = try TypraRuntime.object(data, typeName: "EngineEvent") + var instance = EngineEvent() + if let value = object["sequence"] { + instance.sequence = try TypraRuntime.int64(value, field: "sequence") + } + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["timestamp"] { + instance.timestamp = try TypraRuntime.string(value, field: "timestamp") + } + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["runId"] { + instance.runId = try TypraRuntime.string(value, field: "runId") + } + if let value = object["parentRunId"] { + instance.parentRunId = try TypraRuntime.string(value, field: "parentRunId") + } + if let value = object["delegationDepth"] { + instance.delegationDepth = try TypraRuntime.int32(value, field: "delegationDepth") + } else { + instance.delegationDepth = 0 + } + if let value = object["invocationId"] { + instance.invocationId = try TypraRuntime.string(value, field: "invocationId") + } + if let value = object["iteration"] { + instance.iteration = try TypraRuntime.int32(value, field: "iteration") + } + if let value = object["kind"] { + instance.kind = try EngineEventKind.parse(try TypraRuntime.string(value, field: "kind")) + } + if let value = object["payload"] { + instance.payload = value + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["sequence"] = self.sequence + result["id"] = self.id + result["timestamp"] = self.timestamp + result["sessionId"] = self.sessionId + result["turnId"] = self.turnId + result["runId"] = self.runId + if let value = self.parentRunId { + result["parentRunId"] = value + } + result["delegationDepth"] = self.delegationDepth + if let value = self.invocationId { + result["invocationId"] = value + } + if let value = self.iteration { + result["iteration"] = value + } + result["kind"] = self.kind.rawValue + if let value = self.payload { + result["payload"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> EngineEvent + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "EngineEvent"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> EngineEvent + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "EngineEvent"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/engine_permission_decision.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/engine_permission_decision.swift new file mode 100644 index 000000000..022cc118f --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/engine_permission_decision.swift @@ -0,0 +1,68 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A permission decision for one tool request. +public struct EnginePermissionDecision: TypraModel { + public var approved: Bool = false + public var reason: String? = nil + public var metadata: [String: Any]? = nil + + public init(approved: Bool = false, reason: String? = nil, metadata: [String: Any]? = nil) { + self.approved = approved + self.reason = reason + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> EnginePermissionDecision + { + let object = try TypraRuntime.object(data, typeName: "EnginePermissionDecision") + var instance = EnginePermissionDecision() + if let value = object["approved"] { + instance.approved = try TypraRuntime.bool(value, field: "approved") + } + if let value = object["reason"] { + instance.reason = try TypraRuntime.string(value, field: "reason") + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["approved"] = self.approved + if let value = self.reason { + result["reason"] = value + } + if let value = self.metadata { + result["metadata"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> EnginePermissionDecision + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "EnginePermissionDecision"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> EnginePermissionDecision + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "EnginePermissionDecision"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/event_journal_writer.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/event_journal_writer.swift new file mode 100644 index 000000000..e97f216eb --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/event_journal_writer.swift @@ -0,0 +1,10 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public protocol EventJournalWriter { + func appendTurn(turnEvent: TurnEvent) throws -> Bool + func appendSession(sessionEvent: SessionEvent) throws -> Bool + func close(summary: SessionSummary?) throws -> Bool +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/event_sink.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/event_sink.swift new file mode 100644 index 000000000..811f1c1fe --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/event_sink.swift @@ -0,0 +1,9 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public protocol EventSink { + func emitTurn(turnEvent: TurnEvent) throws -> Bool + func emitSession(sessionEvent: SessionEvent) throws -> Bool +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/executor.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/executor.swift new file mode 100644 index 000000000..cd89d9f89 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/executor.swift @@ -0,0 +1,12 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public protocol Executor { + func execute(agent: Prompty, messages: [Message]) async throws -> Any + func executeStream(agent: Prompty, messages: [Message]) async throws -> Any + func formatToolMessages( + rawResponse: Any, toolCalls: [ToolCall], toolResults: [String], textContent: String? + ) throws -> [Message] +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/final_output_policy_request.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/final_output_policy_request.swift new file mode 100644 index 000000000..73125d35a --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/final_output_policy_request.swift @@ -0,0 +1,91 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Final output supplied to the host policy immediately before a success commit. +public struct FinalOutputPolicyRequest: TypraModel { + public var sessionId: String = "" + public var turnId: String = "" + public var iteration: Int32 = 0 + public var messages: [Message] = [] + public var output: Any? = nil + public var inputs: Any? = nil + + public init( + sessionId: String = "", turnId: String = "", iteration: Int32 = 0, messages: [Message] = [], + output: Any? = nil, inputs: Any? = nil + ) { + self.sessionId = sessionId + self.turnId = turnId + self.iteration = iteration + self.messages = messages + self.output = output + self.inputs = inputs + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> FinalOutputPolicyRequest + { + let object = try TypraRuntime.object(data, typeName: "FinalOutputPolicyRequest") + var instance = FinalOutputPolicyRequest() + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["iteration"] { + instance.iteration = try TypraRuntime.int32(value, field: "iteration") + } + if let value = object["messages"] { + instance.messages = try TypraRuntime.array(value, field: "messages").map { + try Message.load($0, context: context) + } + } + if let value = object["output"] { + instance.output = value + } + if let value = object["inputs"] { + instance.inputs = value + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["sessionId"] = self.sessionId + result["turnId"] = self.turnId + result["iteration"] = self.iteration + result["messages"] = try self.messages.map { try $0.save(context) } + if let value = self.output { + result["output"] = value + } + if let value = self.inputs { + result["inputs"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> FinalOutputPolicyRequest + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "FinalOutputPolicyRequest"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> FinalOutputPolicyRequest + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "FinalOutputPolicyRequest"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/final_output_policy_result.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/final_output_policy_result.swift new file mode 100644 index 000000000..d5934e0f9 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/final_output_policy_result.swift @@ -0,0 +1,62 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Final output rewrite produced by the host policy before commit. +public struct FinalOutputPolicyResult: TypraModel { + public var output: Any? = nil + public var metadata: [String: Any]? = nil + + public init(output: Any? = nil, metadata: [String: Any]? = nil) { + self.output = output + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> FinalOutputPolicyResult + { + let object = try TypraRuntime.object(data, typeName: "FinalOutputPolicyResult") + var instance = FinalOutputPolicyResult() + if let value = object["output"] { + instance.output = value + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.output { + result["output"] = value + } + if let value = self.metadata { + result["metadata"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> FinalOutputPolicyResult + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "FinalOutputPolicyResult"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> FinalOutputPolicyResult + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "FinalOutputPolicyResult"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/host_policy_request.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/host_policy_request.swift new file mode 100644 index 000000000..c2e95f1b2 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/host_policy_request.swift @@ -0,0 +1,91 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Host-owned deterministic state supplied before one model invocation. +public struct HostPolicyRequest: TypraModel { + public var sessionId: String = "" + public var turnId: String = "" + public var iteration: Int32 = 0 + public var messages: [Message] = [] + public var stablePrefixMessages: Int32 = 0 + public var inputs: Any? = nil + + public init( + sessionId: String = "", turnId: String = "", iteration: Int32 = 0, messages: [Message] = [], + stablePrefixMessages: Int32 = 0, inputs: Any? = nil + ) { + self.sessionId = sessionId + self.turnId = turnId + self.iteration = iteration + self.messages = messages + self.stablePrefixMessages = stablePrefixMessages + self.inputs = inputs + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> HostPolicyRequest + { + let object = try TypraRuntime.object(data, typeName: "HostPolicyRequest") + var instance = HostPolicyRequest() + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["iteration"] { + instance.iteration = try TypraRuntime.int32(value, field: "iteration") + } + if let value = object["messages"] { + instance.messages = try TypraRuntime.array(value, field: "messages").map { + try Message.load($0, context: context) + } + } + if let value = object["stablePrefixMessages"] { + instance.stablePrefixMessages = try TypraRuntime.int32(value, field: "stablePrefixMessages") + } else { + instance.stablePrefixMessages = 0 + } + if let value = object["inputs"] { + instance.inputs = value + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["sessionId"] = self.sessionId + result["turnId"] = self.turnId + result["iteration"] = self.iteration + result["messages"] = try self.messages.map { try $0.save(context) } + result["stablePrefixMessages"] = self.stablePrefixMessages + if let value = self.inputs { + result["inputs"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> HostPolicyRequest + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "HostPolicyRequest"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> HostPolicyRequest + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "HostPolicyRequest"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/host_policy_result.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/host_policy_result.swift new file mode 100644 index 000000000..9bb749f45 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/host_policy_result.swift @@ -0,0 +1,72 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// State rewrite produced by the host policy before a model invocation. +public struct HostPolicyResult: TypraModel { + public var messages: [Message] = [] + public var stablePrefixMessages: Int32 = 0 + public var metadata: [String: Any]? = nil + + public init( + messages: [Message] = [], stablePrefixMessages: Int32 = 0, metadata: [String: Any]? = nil + ) { + self.messages = messages + self.stablePrefixMessages = stablePrefixMessages + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> HostPolicyResult + { + let object = try TypraRuntime.object(data, typeName: "HostPolicyResult") + var instance = HostPolicyResult() + if let value = object["messages"] { + instance.messages = try TypraRuntime.array(value, field: "messages").map { + try Message.load($0, context: context) + } + } + if let value = object["stablePrefixMessages"] { + instance.stablePrefixMessages = try TypraRuntime.int32(value, field: "stablePrefixMessages") + } else { + instance.stablePrefixMessages = 0 + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["messages"] = try self.messages.map { try $0.save(context) } + result["stablePrefixMessages"] = self.stablePrefixMessages + if let value = self.metadata { + result["metadata"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> HostPolicyResult + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "HostPolicyResult"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> HostPolicyResult + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "HostPolicyResult"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/host_tool_executor.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/host_tool_executor.swift new file mode 100644 index 000000000..4a3a205cd --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/host_tool_executor.swift @@ -0,0 +1,8 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public protocol HostToolExecutor { + func execute(request: HostToolRequest) async throws -> HostToolResult +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/invocation_context_decision.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/invocation_context_decision.swift new file mode 100644 index 000000000..62b587721 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/invocation_context_decision.swift @@ -0,0 +1,107 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum InvocationContextDisposition: String, Codable, CaseIterable { + case included = "included" + case excluded = "excluded" + public static func parse(_ value: String) throws -> InvocationContextDisposition { + switch value { + case "included": return .included + case "excluded": return .excluded + default: throw TypraRuntimeError.invalidEnum(type: "InvocationContextDisposition", value: value) + } + } +} + +/// An auditable decision made while preparing model-visible context. +public struct InvocationContextDecision: TypraModel { + public var candidateId: String = "" + public var disposition: InvocationContextDisposition = + (try! InvocationContextDisposition.parse("included")) + public var reason: String = "" + public var rank: Int32? = nil + public var estimatedTokens: Int32? = nil + public var metadata: [String: Any]? = nil + + public init( + candidateId: String = "", + disposition: InvocationContextDisposition = + (try! InvocationContextDisposition.parse("included")), reason: String = "", + rank: Int32? = nil, estimatedTokens: Int32? = nil, metadata: [String: Any]? = nil + ) { + self.candidateId = candidateId + self.disposition = disposition + self.reason = reason + self.rank = rank + self.estimatedTokens = estimatedTokens + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> InvocationContextDecision + { + let object = try TypraRuntime.object(data, typeName: "InvocationContextDecision") + var instance = InvocationContextDecision() + if let value = object["candidateId"] { + instance.candidateId = try TypraRuntime.string(value, field: "candidateId") + } + if let value = object["disposition"] { + instance.disposition = try InvocationContextDisposition.parse( + try TypraRuntime.string(value, field: "disposition")) + } + if let value = object["reason"] { + instance.reason = try TypraRuntime.string(value, field: "reason") + } + if let value = object["rank"] { + instance.rank = try TypraRuntime.int32(value, field: "rank") + } + if let value = object["estimatedTokens"] { + instance.estimatedTokens = try TypraRuntime.int32(value, field: "estimatedTokens") + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["candidateId"] = self.candidateId + result["disposition"] = self.disposition.rawValue + result["reason"] = self.reason + if let value = self.rank { + result["rank"] = value + } + if let value = self.estimatedTokens { + result["estimatedTokens"] = value + } + if let value = self.metadata { + result["metadata"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> InvocationContextDecision + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "InvocationContextDecision"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> InvocationContextDecision + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "InvocationContextDecision"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/invocation_context_state.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/invocation_context_state.swift new file mode 100644 index 000000000..cc42dd05f --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/invocation_context_state.swift @@ -0,0 +1,84 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum InvocationContextPortability: String, Codable, CaseIterable { + case portable = "portable" + case delegated = "delegated" + case opaque = "opaque" + public static func parse(_ value: String) throws -> InvocationContextPortability { + switch value { + case "portable": return .portable + case "delegated": return .delegated + case "opaque": return .opaque + default: throw TypraRuntimeError.invalidEnum(type: "InvocationContextPortability", value: value) + } + } +} + +/// Provider-context state carried into or out of an invocation. +public struct InvocationContextState: TypraModel { + public var portability: InvocationContextPortability = + (try! InvocationContextPortability.parse("portable")) + public var delegatedState: [DelegatedStateReference]? = nil + + public init( + portability: InvocationContextPortability = + (try! InvocationContextPortability.parse("portable")), + delegatedState: [DelegatedStateReference]? = nil + ) { + self.portability = portability + self.delegatedState = delegatedState + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> InvocationContextState + { + let object = try TypraRuntime.object(data, typeName: "InvocationContextState") + var instance = InvocationContextState() + if let value = object["portability"] { + instance.portability = try InvocationContextPortability.parse( + try TypraRuntime.string(value, field: "portability")) + } else { + instance.portability = (try! InvocationContextPortability.parse("portable")) + } + if let value = object["delegatedState"] { + instance.delegatedState = try TypraRuntime.array(value, field: "delegatedState").map { + try DelegatedStateReference.load($0, context: context) + } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["portability"] = self.portability.rawValue + if let value = self.delegatedState { + result["delegatedState"] = try value.map { try $0.save(context) } + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> InvocationContextState + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "InvocationContextState"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> InvocationContextState + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "InvocationContextState"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_invocation_context_snapshot.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_invocation_context_snapshot.swift new file mode 100644 index 000000000..2190a9acc --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_invocation_context_snapshot.swift @@ -0,0 +1,123 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Immutable model-visible context for a single provider invocation. Retries of the same invocation MUST reuse the same snapshot. +public struct ModelInvocationContextSnapshot: TypraModel { + public var id: String = "" + public var sessionId: String = "" + public var turnId: String = "" + public var invocationId: String = "" + public var iteration: Int32 = 0 + public var messages: [Message] = [] + public var decisions: [InvocationContextDecision]? = nil + public var stablePrefixMessages: Int32 = 0 + public var contextState: InvocationContextState = InvocationContextState() + public var metadata: [String: Any]? = nil + + public init( + id: String = "", sessionId: String = "", turnId: String = "", invocationId: String = "", + iteration: Int32 = 0, messages: [Message] = [], decisions: [InvocationContextDecision]? = nil, + stablePrefixMessages: Int32 = 0, + contextState: InvocationContextState = InvocationContextState(), metadata: [String: Any]? = nil + ) { + self.id = id + self.sessionId = sessionId + self.turnId = turnId + self.invocationId = invocationId + self.iteration = iteration + self.messages = messages + self.decisions = decisions + self.stablePrefixMessages = stablePrefixMessages + self.contextState = contextState + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ModelInvocationContextSnapshot + { + let object = try TypraRuntime.object(data, typeName: "ModelInvocationContextSnapshot") + var instance = ModelInvocationContextSnapshot() + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["invocationId"] { + instance.invocationId = try TypraRuntime.string(value, field: "invocationId") + } + if let value = object["iteration"] { + instance.iteration = try TypraRuntime.int32(value, field: "iteration") + } + if let value = object["messages"] { + instance.messages = try TypraRuntime.array(value, field: "messages").map { + try Message.load($0, context: context) + } + } + if let value = object["decisions"] { + instance.decisions = try TypraRuntime.array(value, field: "decisions").map { + try InvocationContextDecision.load($0, context: context) + } + } + if let value = object["stablePrefixMessages"] { + instance.stablePrefixMessages = try TypraRuntime.int32(value, field: "stablePrefixMessages") + } else { + instance.stablePrefixMessages = 0 + } + if let value = object["contextState"] { + instance.contextState = try InvocationContextState.load(value, context: context) + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["id"] = self.id + result["sessionId"] = self.sessionId + result["turnId"] = self.turnId + result["invocationId"] = self.invocationId + result["iteration"] = self.iteration + result["messages"] = try self.messages.map { try $0.save(context) } + if let value = self.decisions { + result["decisions"] = try value.map { try $0.save(context) } + } + result["stablePrefixMessages"] = self.stablePrefixMessages + result["contextState"] = try self.contextState.save(context) + if let value = self.metadata { + result["metadata"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ModelInvocationContextSnapshot + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ModelInvocationContextSnapshot"), + context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ModelInvocationContextSnapshot + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ModelInvocationContextSnapshot"), + context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_invocation_request.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_invocation_request.swift new file mode 100644 index 000000000..5f0233227 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_invocation_request.swift @@ -0,0 +1,52 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Normalized request for a single model-provider invocation. +public struct ModelInvocationRequest: TypraModel { + public var context: ModelInvocationContextSnapshot = ModelInvocationContextSnapshot() + + public init(context: ModelInvocationContextSnapshot = ModelInvocationContextSnapshot()) { + self.context = context + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ModelInvocationRequest + { + let object = try TypraRuntime.object(data, typeName: "ModelInvocationRequest") + var instance = ModelInvocationRequest() + if let value = object["context"] { + instance.context = try ModelInvocationContextSnapshot.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["context"] = try self.context.save(context) + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ModelInvocationRequest + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ModelInvocationRequest"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ModelInvocationRequest + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ModelInvocationRequest"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_invocation_response.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_invocation_response.swift new file mode 100644 index 000000000..011f99e06 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_invocation_response.swift @@ -0,0 +1,102 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Provider-neutral result of a single model invocation. This is the live provider boundary. It is intentionally distinct from TurnModelResponse, which is the deterministic reference-runner callback. +public struct ModelInvocationResponse: TypraModel { + public var output: Any? = nil + public var usage: InvocationUsage? = nil + public var assistantMessages: [Message]? = nil + public var toolRequests: [ModelToolRequest]? = nil + public var nextContextState: InvocationContextState? = nil + public var metadata: [String: Any]? = nil + + public init( + output: Any? = nil, usage: InvocationUsage? = nil, assistantMessages: [Message]? = nil, + toolRequests: [ModelToolRequest]? = nil, nextContextState: InvocationContextState? = nil, + metadata: [String: Any]? = nil + ) { + self.output = output + self.usage = usage + self.assistantMessages = assistantMessages + self.toolRequests = toolRequests + self.nextContextState = nextContextState + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ModelInvocationResponse + { + let object = try TypraRuntime.object(data, typeName: "ModelInvocationResponse") + var instance = ModelInvocationResponse() + if let value = object["output"] { + instance.output = value + } + if let value = object["usage"] { + instance.usage = try InvocationUsage.load(value, context: context) + } + if let value = object["assistantMessages"] { + instance.assistantMessages = try TypraRuntime.array(value, field: "assistantMessages").map { + try Message.load($0, context: context) + } + } + if let value = object["toolRequests"] { + instance.toolRequests = try TypraRuntime.array(value, field: "toolRequests").map { + try ModelToolRequest.load($0, context: context) + } + } + if let value = object["nextContextState"] { + instance.nextContextState = try InvocationContextState.load(value, context: context) + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.output { + result["output"] = value + } + if let value = self.usage { + result["usage"] = try value.save(context) + } + if let value = self.assistantMessages { + result["assistantMessages"] = try value.map { try $0.save(context) } + } + if let value = self.toolRequests { + result["toolRequests"] = try value.map { try $0.save(context) } + } + if let value = self.nextContextState { + result["nextContextState"] = try value.save(context) + } + if let value = self.metadata { + result["metadata"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ModelInvocationResponse + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ModelInvocationResponse"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ModelInvocationResponse + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ModelInvocationResponse"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_reconciliation_state.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_reconciliation_state.swift new file mode 100644 index 000000000..192f73e55 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_reconciliation_state.swift @@ -0,0 +1,81 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Durable state required to reconcile one indeterminate model invocation. Retained when a model effect completed with an unknown outcome so a resumed run can determine success or failure without re-invoking the provider. +public struct ModelReconciliationState: TypraModel { + public var invocationId: String = "" + public var request: ModelInvocationRequest = ModelInvocationRequest() + public var failedAttempt: Int32 = 0 + public var message: String = "" + public var metadata: [String: Any]? = nil + + public init( + invocationId: String = "", request: ModelInvocationRequest = ModelInvocationRequest(), + failedAttempt: Int32 = 0, message: String = "", metadata: [String: Any]? = nil + ) { + self.invocationId = invocationId + self.request = request + self.failedAttempt = failedAttempt + self.message = message + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ModelReconciliationState + { + let object = try TypraRuntime.object(data, typeName: "ModelReconciliationState") + var instance = ModelReconciliationState() + if let value = object["invocationId"] { + instance.invocationId = try TypraRuntime.string(value, field: "invocationId") + } + if let value = object["request"] { + instance.request = try ModelInvocationRequest.load(value, context: context) + } + if let value = object["failedAttempt"] { + instance.failedAttempt = try TypraRuntime.int32(value, field: "failedAttempt") + } + if let value = object["message"] { + instance.message = try TypraRuntime.string(value, field: "message") + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["invocationId"] = self.invocationId + result["request"] = try self.request.save(context) + result["failedAttempt"] = self.failedAttempt + result["message"] = self.message + if let value = self.metadata { + result["metadata"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ModelReconciliationState + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ModelReconciliationState"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ModelReconciliationState + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ModelReconciliationState"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_tool_request.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_tool_request.swift new file mode 100644 index 000000000..b8e4d8e7b --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_tool_request.swift @@ -0,0 +1,76 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A normalized tool request returned by a model provider. Arguments remain JSON-shaped because providers may return a parsed object or a serialized JSON value before the host's tool binding is selected. +public struct ModelToolRequest: TypraModel { + public var id: String = "" + public var name: String = "" + public var arguments: Any? = nil + public var metadata: [String: Any]? = nil + + public init( + id: String = "", name: String = "", arguments: Any? = nil, metadata: [String: Any]? = nil + ) { + self.id = id + self.name = name + self.arguments = arguments + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ModelToolRequest + { + let object = try TypraRuntime.object(data, typeName: "ModelToolRequest") + var instance = ModelToolRequest() + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["arguments"] { + instance.arguments = value + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["id"] = self.id + result["name"] = self.name + if let value = self.arguments { + result["arguments"] = value + } + if let value = self.metadata { + result["metadata"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ModelToolRequest + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ModelToolRequest"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ModelToolRequest + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ModelToolRequest"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_tool_result.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_tool_result.swift new file mode 100644 index 000000000..531105cec --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_tool_result.swift @@ -0,0 +1,107 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum ModelToolOutcome: String, Codable, CaseIterable { + case success = "success" + case failed = "failed" + case indeterminate = "indeterminate" + public static func parse(_ value: String) throws -> ModelToolOutcome { + switch value { + case "success": return .success + case "failed": return .failed + case "indeterminate": return .indeterminate + default: throw TypraRuntimeError.invalidEnum(type: "ModelToolOutcome", value: value) + } + } +} + +/// Normalized result of executing one model-requested tool. +public struct ModelToolResult: TypraModel { + public var requestId: String = "" + public var name: String = "" + public var outcome: ModelToolOutcome = (try! ModelToolOutcome.parse("success")) + public var output: Any? = nil + public var errorKind: String? = nil + public var metadata: [String: Any]? = nil + + public init( + requestId: String = "", name: String = "", + outcome: ModelToolOutcome = (try! ModelToolOutcome.parse("success")), output: Any? = nil, + errorKind: String? = nil, metadata: [String: Any]? = nil + ) { + self.requestId = requestId + self.name = name + self.outcome = outcome + self.output = output + self.errorKind = errorKind + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ModelToolResult + { + let object = try TypraRuntime.object(data, typeName: "ModelToolResult") + var instance = ModelToolResult() + if let value = object["requestId"] { + instance.requestId = try TypraRuntime.string(value, field: "requestId") + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["outcome"] { + instance.outcome = try ModelToolOutcome.parse( + try TypraRuntime.string(value, field: "outcome")) + } + if let value = object["output"] { + instance.output = value + } + if let value = object["errorKind"] { + instance.errorKind = try TypraRuntime.string(value, field: "errorKind") + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["requestId"] = self.requestId + result["name"] = self.name + result["outcome"] = self.outcome.rawValue + if let value = self.output { + result["output"] = value + } + if let value = self.errorKind { + result["errorKind"] = value + } + if let value = self.metadata { + result["metadata"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ModelToolResult + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ModelToolResult"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ModelToolResult + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ModelToolResult"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/parser.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/parser.swift new file mode 100644 index 000000000..a8643fa10 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/parser.swift @@ -0,0 +1,10 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public protocol Parser { + func preRender(template: String) throws -> Any? + func parse(agent: Prompty, rendered: String, context: [String: Any]?) async throws + -> [Message] +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/permission_resolver.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/permission_resolver.swift new file mode 100644 index 000000000..b4a8bcfda --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/permission_resolver.swift @@ -0,0 +1,8 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public protocol PermissionResolver { + func request(request: PermissionRequest) async throws -> PermissionDecision +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/processor.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/processor.swift new file mode 100644 index 000000000..ebeb00ecb --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/processor.swift @@ -0,0 +1,9 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public protocol Processor { + func process(agent: Prompty, response: Any) async throws -> Any + func processStream(stream: Any) async throws -> Any +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/renderer.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/renderer.swift new file mode 100644 index 000000000..d1282891e --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/renderer.swift @@ -0,0 +1,8 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public protocol Renderer { + func render(agent: Prompty, template: String, inputs: [String: Any]) async throws -> String +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_journal_record.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_journal_record.swift new file mode 100644 index 000000000..030158674 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_journal_record.swift @@ -0,0 +1,174 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum ReplayRecordKind: String, Codable, CaseIterable { + case session = "session" + case turn = "turn" + case summary = "summary" + public static func parse(_ value: String) throws -> ReplayRecordKind { + switch value { + case "session": return .session + case "turn": return .turn + case "summary": return .summary + default: throw TypraRuntimeError.invalidEnum(type: "ReplayRecordKind", value: value) + } + } +} + +public enum ReplayRecordStatus: String, Codable, CaseIterable { + case success = "success" + case error = "error" + case cancelled = "cancelled" + public static func parse(_ value: String) throws -> ReplayRecordStatus { + switch value { + case "success": return .success + case "error": return .error + case "cancelled": return .cancelled + default: throw TypraRuntimeError.invalidEnum(type: "ReplayRecordStatus", value: value) + } + } +} + +/// Stable, replay-comparable projection of a journal record. Runtime journal records may carry additional payload fields, durations, telemetry, or provider-specific data. Replay verification compares this normalized shape so deterministic orchestration semantics are mechanically shared across runtimes. +public struct ReplayJournalRecord: TypraModel { + public var kind: ReplayRecordKind = (try! ReplayRecordKind.parse("session")) + public var type: String? = nil + public var sessionId: String? = nil + public var turnId: String? = nil + public var iteration: Int32? = nil + public var status: ReplayRecordStatus? = nil + public var requestId: String? = nil + public var toolName: String? = nil + public var success: Bool? = nil + public var errorKind: String? = nil + public var turns: Int32? = nil + public var checkpoints: Int32? = nil + + public init( + kind: ReplayRecordKind = (try! ReplayRecordKind.parse("session")), type: String? = nil, + sessionId: String? = nil, turnId: String? = nil, iteration: Int32? = nil, + status: ReplayRecordStatus? = nil, requestId: String? = nil, toolName: String? = nil, + success: Bool? = nil, errorKind: String? = nil, turns: Int32? = nil, checkpoints: Int32? = nil + ) { + self.kind = kind + self.type = type + self.sessionId = sessionId + self.turnId = turnId + self.iteration = iteration + self.status = status + self.requestId = requestId + self.toolName = toolName + self.success = success + self.errorKind = errorKind + self.turns = turns + self.checkpoints = checkpoints + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ReplayJournalRecord + { + let object = try TypraRuntime.object(data, typeName: "ReplayJournalRecord") + var instance = ReplayJournalRecord() + if let value = object["kind"] { + instance.kind = try ReplayRecordKind.parse(try TypraRuntime.string(value, field: "kind")) + } + if let value = object["type"] { + instance.type = try TypraRuntime.string(value, field: "type") + } + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["iteration"] { + instance.iteration = try TypraRuntime.int32(value, field: "iteration") + } + if let value = object["status"] { + instance.status = try ReplayRecordStatus.parse( + try TypraRuntime.string(value, field: "status")) + } + if let value = object["requestId"] { + instance.requestId = try TypraRuntime.string(value, field: "requestId") + } + if let value = object["toolName"] { + instance.toolName = try TypraRuntime.string(value, field: "toolName") + } + if let value = object["success"] { + instance.success = try TypraRuntime.bool(value, field: "success") + } + if let value = object["errorKind"] { + instance.errorKind = try TypraRuntime.string(value, field: "errorKind") + } + if let value = object["turns"] { + instance.turns = try TypraRuntime.int32(value, field: "turns") + } + if let value = object["checkpoints"] { + instance.checkpoints = try TypraRuntime.int32(value, field: "checkpoints") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind.rawValue + if let value = self.type { + result["type"] = value + } + if let value = self.sessionId { + result["sessionId"] = value + } + if let value = self.turnId { + result["turnId"] = value + } + if let value = self.iteration { + result["iteration"] = value + } + if let value = self.status { + result["status"] = value.rawValue + } + if let value = self.requestId { + result["requestId"] = value + } + if let value = self.toolName { + result["toolName"] = value + } + if let value = self.success { + result["success"] = value + } + if let value = self.errorKind { + result["errorKind"] = value + } + if let value = self.turns { + result["turns"] = value + } + if let value = self.checkpoints { + result["checkpoints"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ReplayJournalRecord + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ReplayJournalRecord"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ReplayJournalRecord + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ReplayJournalRecord"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_mismatch.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_mismatch.swift new file mode 100644 index 000000000..ada8cd085 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_mismatch.swift @@ -0,0 +1,77 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A single mismatch produced by replay verification. +public struct ReplayMismatch: TypraModel { + public var index: Int32 = 0 + public var expected: ReplayJournalRecord? = nil + public var actual: ReplayJournalRecord? = nil + public var message: String = "" + + public init( + index: Int32 = 0, expected: ReplayJournalRecord? = nil, actual: ReplayJournalRecord? = nil, + message: String = "" + ) { + self.index = index + self.expected = expected + self.actual = actual + self.message = message + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ReplayMismatch + { + let object = try TypraRuntime.object(data, typeName: "ReplayMismatch") + var instance = ReplayMismatch() + if let value = object["index"] { + instance.index = try TypraRuntime.int32(value, field: "index") + } + if let value = object["expected"] { + instance.expected = try ReplayJournalRecord.load(value, context: context) + } + if let value = object["actual"] { + instance.actual = try ReplayJournalRecord.load(value, context: context) + } + if let value = object["message"] { + instance.message = try TypraRuntime.string(value, field: "message") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["index"] = self.index + if let value = self.expected { + result["expected"] = try value.save(context) + } + if let value = self.actual { + result["actual"] = try value.save(context) + } + result["message"] = self.message + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ReplayMismatch + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ReplayMismatch"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ReplayMismatch + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ReplayMismatch"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_verification_request.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_verification_request.swift new file mode 100644 index 000000000..ee91fd3dc --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_verification_request.swift @@ -0,0 +1,62 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Request accepted by a replay verifier implementation. +public struct ReplayVerificationRequest: TypraModel { + public var expected: [ReplayJournalRecord] = [] + public var actual: [ReplayJournalRecord] = [] + + public init(expected: [ReplayJournalRecord] = [], actual: [ReplayJournalRecord] = []) { + self.expected = expected + self.actual = actual + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ReplayVerificationRequest + { + let object = try TypraRuntime.object(data, typeName: "ReplayVerificationRequest") + var instance = ReplayVerificationRequest() + if let value = object["expected"] { + instance.expected = try TypraRuntime.array(value, field: "expected").map { + try ReplayJournalRecord.load($0, context: context) + } + } + if let value = object["actual"] { + instance.actual = try TypraRuntime.array(value, field: "actual").map { + try ReplayJournalRecord.load($0, context: context) + } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["expected"] = try self.expected.map { try $0.save(context) } + result["actual"] = try self.actual.map { try $0.save(context) } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ReplayVerificationRequest + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ReplayVerificationRequest"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ReplayVerificationRequest + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ReplayVerificationRequest"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_verification_result.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_verification_result.swift new file mode 100644 index 000000000..5c5734cd7 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_verification_result.swift @@ -0,0 +1,90 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum ReplayVerificationStatus: String, Codable, CaseIterable { + case passed = "passed" + case failed = "failed" + public static func parse(_ value: String) throws -> ReplayVerificationStatus { + switch value { + case "passed": return .passed + case "failed": return .failed + default: throw TypraRuntimeError.invalidEnum(type: "ReplayVerificationStatus", value: value) + } + } +} + +/// Result returned by a replay verifier implementation. +public struct ReplayVerificationResult: TypraModel { + public var status: ReplayVerificationStatus = (try! ReplayVerificationStatus.parse("passed")) + public var mismatches: [ReplayMismatch]? = nil + public var expectedCount: Int32 = 0 + public var actualCount: Int32 = 0 + + public init( + status: ReplayVerificationStatus = (try! ReplayVerificationStatus.parse("passed")), + mismatches: [ReplayMismatch]? = nil, expectedCount: Int32 = 0, actualCount: Int32 = 0 + ) { + self.status = status + self.mismatches = mismatches + self.expectedCount = expectedCount + self.actualCount = actualCount + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ReplayVerificationResult + { + let object = try TypraRuntime.object(data, typeName: "ReplayVerificationResult") + var instance = ReplayVerificationResult() + if let value = object["status"] { + instance.status = try ReplayVerificationStatus.parse( + try TypraRuntime.string(value, field: "status")) + } + if let value = object["mismatches"] { + instance.mismatches = try TypraRuntime.array(value, field: "mismatches").map { + try ReplayMismatch.load($0, context: context) + } + } + if let value = object["expectedCount"] { + instance.expectedCount = try TypraRuntime.int32(value, field: "expectedCount") + } + if let value = object["actualCount"] { + instance.actualCount = try TypraRuntime.int32(value, field: "actualCount") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["status"] = self.status.rawValue + if let value = self.mismatches { + result["mismatches"] = try value.map { try $0.save(context) } + } + result["expectedCount"] = self.expectedCount + result["actualCount"] = self.actualCount + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ReplayVerificationResult + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ReplayVerificationResult"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ReplayVerificationResult + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ReplayVerificationResult"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/resume_context.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/resume_context.swift new file mode 100644 index 000000000..e6b9c68c9 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/resume_context.swift @@ -0,0 +1,82 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Input that drives resuming a turn from a durable checkpoint. A host supplies this to restart an interrupted turn without duplicating a committed model or tool effect. +public struct ResumeContext: TypraModel { + public var checkpoint: EngineCheckpoint = EngineCheckpoint() + public var maxIterations: Int32 = 0 + public var maxModelAttempts: Int32 = 0 + public var lastJournalSequence: Int64 = 0 + public var metadata: [String: Any]? = nil + + public init( + checkpoint: EngineCheckpoint = EngineCheckpoint(), maxIterations: Int32 = 0, + maxModelAttempts: Int32 = 0, lastJournalSequence: Int64 = 0, metadata: [String: Any]? = nil + ) { + self.checkpoint = checkpoint + self.maxIterations = maxIterations + self.maxModelAttempts = maxModelAttempts + self.lastJournalSequence = lastJournalSequence + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> ResumeContext + { + let object = try TypraRuntime.object(data, typeName: "ResumeContext") + var instance = ResumeContext() + if let value = object["checkpoint"] { + instance.checkpoint = try EngineCheckpoint.load(value, context: context) + } + if let value = object["maxIterations"] { + instance.maxIterations = try TypraRuntime.int32(value, field: "maxIterations") + } + if let value = object["maxModelAttempts"] { + instance.maxModelAttempts = try TypraRuntime.int32(value, field: "maxModelAttempts") + } + if let value = object["lastJournalSequence"] { + instance.lastJournalSequence = try TypraRuntime.int64(value, field: "lastJournalSequence") + } else { + instance.lastJournalSequence = 0 + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["checkpoint"] = try self.checkpoint.save(context) + result["maxIterations"] = self.maxIterations + result["maxModelAttempts"] = self.maxModelAttempts + result["lastJournalSequence"] = self.lastJournalSequence + if let value = self.metadata { + result["metadata"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ResumeContext + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ResumeContext"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ResumeContext + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ResumeContext"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/retry_policy_request.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/retry_policy_request.swift new file mode 100644 index 000000000..990077821 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/retry_policy_request.swift @@ -0,0 +1,72 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Context supplied to the retry policy after a retryable model failure. +public struct RetryPolicyRequest: TypraModel { + public var failedAttempts: Int32 = 0 + public var nextAttempt: Int32 = 0 + public var maxAttempts: Int32 = 0 + public var reason: String = "" + + public init( + failedAttempts: Int32 = 0, nextAttempt: Int32 = 0, maxAttempts: Int32 = 0, reason: String = "" + ) { + self.failedAttempts = failedAttempts + self.nextAttempt = nextAttempt + self.maxAttempts = maxAttempts + self.reason = reason + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> RetryPolicyRequest + { + let object = try TypraRuntime.object(data, typeName: "RetryPolicyRequest") + var instance = RetryPolicyRequest() + if let value = object["failedAttempts"] { + instance.failedAttempts = try TypraRuntime.int32(value, field: "failedAttempts") + } + if let value = object["nextAttempt"] { + instance.nextAttempt = try TypraRuntime.int32(value, field: "nextAttempt") + } + if let value = object["maxAttempts"] { + instance.maxAttempts = try TypraRuntime.int32(value, field: "maxAttempts") + } + if let value = object["reason"] { + instance.reason = try TypraRuntime.string(value, field: "reason") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["failedAttempts"] = self.failedAttempts + result["nextAttempt"] = self.nextAttempt + result["maxAttempts"] = self.maxAttempts + result["reason"] = self.reason + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> RetryPolicyRequest + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "RetryPolicyRequest"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> RetryPolicyRequest + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "RetryPolicyRequest"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/run_turn_request.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/run_turn_request.swift new file mode 100644 index 000000000..41f06f627 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/run_turn_request.swift @@ -0,0 +1,77 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Request accepted by a reference turn runner implementation. +public struct RunTurnRequest: TypraModel { + public var sessionId: String = "" + public var turnId: String = "" + public var inputs: [String: Any]? = nil + public var options: TurnOptions? = nil + + public init( + sessionId: String = "", turnId: String = "", inputs: [String: Any]? = nil, + options: TurnOptions? = nil + ) { + self.sessionId = sessionId + self.turnId = turnId + self.inputs = inputs + self.options = options + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> RunTurnRequest + { + let object = try TypraRuntime.object(data, typeName: "RunTurnRequest") + var instance = RunTurnRequest() + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["inputs"] { + instance.inputs = try TypraRuntime.dictionary(value, field: "inputs") + } + if let value = object["options"] { + instance.options = try TurnOptions.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["sessionId"] = self.sessionId + result["turnId"] = self.turnId + if let value = self.inputs { + result["inputs"] = value + } + if let value = self.options { + result["options"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> RunTurnRequest + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "RunTurnRequest"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> RunTurnRequest + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "RunTurnRequest"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/run_turn_result.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/run_turn_result.swift new file mode 100644 index 000000000..f5d1352b5 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/run_turn_result.swift @@ -0,0 +1,115 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum RunTurnStatus: String, Codable, CaseIterable { + case success = "success" + case error = "error" + case cancelled = "cancelled" + public static func parse(_ value: String) throws -> RunTurnStatus { + switch value { + case "success": return .success + case "error": return .error + case "cancelled": return .cancelled + default: throw TypraRuntimeError.invalidEnum(type: "RunTurnStatus", value: value) + } + } +} + +/// Result returned by a reference turn runner implementation. +public struct RunTurnResult: TypraModel { + public var sessionId: String = "" + public var turnId: String = "" + public var status: RunTurnStatus = (try! RunTurnStatus.parse("success")) + public var output: Any? = nil + public var iterations: Int32 = 0 + public var toolResults: [HostToolResult]? = nil + public var checkpoints: [Checkpoint]? = nil + + public init( + sessionId: String = "", turnId: String = "", + status: RunTurnStatus = (try! RunTurnStatus.parse("success")), output: Any? = nil, + iterations: Int32 = 0, toolResults: [HostToolResult]? = nil, checkpoints: [Checkpoint]? = nil + ) { + self.sessionId = sessionId + self.turnId = turnId + self.status = status + self.output = output + self.iterations = iterations + self.toolResults = toolResults + self.checkpoints = checkpoints + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> RunTurnResult + { + let object = try TypraRuntime.object(data, typeName: "RunTurnResult") + var instance = RunTurnResult() + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["status"] { + instance.status = try RunTurnStatus.parse(try TypraRuntime.string(value, field: "status")) + } + if let value = object["output"] { + instance.output = value + } + if let value = object["iterations"] { + instance.iterations = try TypraRuntime.int32(value, field: "iterations") + } + if let value = object["toolResults"] { + instance.toolResults = try TypraRuntime.array(value, field: "toolResults").map { + try HostToolResult.load($0, context: context) + } + } + if let value = object["checkpoints"] { + instance.checkpoints = try TypraRuntime.array(value, field: "checkpoints").map { + try Checkpoint.load($0, context: context) + } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["sessionId"] = self.sessionId + result["turnId"] = self.turnId + result["status"] = self.status.rawValue + if let value = self.output { + result["output"] = value + } + result["iterations"] = self.iterations + if let value = self.toolResults { + result["toolResults"] = try value.map { try $0.save(context) } + } + if let value = self.checkpoints { + result["checkpoints"] = try value.map { try $0.save(context) } + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> RunTurnResult + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "RunTurnResult"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> RunTurnResult + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "RunTurnResult"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_commit.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_commit.swift new file mode 100644 index 000000000..db635cf8c --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_commit.swift @@ -0,0 +1,124 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum EngineTurnStatus: String, Codable, CaseIterable { + case success = "success" + case failed = "failed" + case cancelled = "cancelled" + case reconciliationRequired = "reconciliation_required" + public static func parse(_ value: String) throws -> EngineTurnStatus { + switch value { + case "success": return .success + case "failed": return .failed + case "cancelled": return .cancelled + case "reconciliation_required": return .reconciliationRequired + default: throw TypraRuntimeError.invalidEnum(type: "EngineTurnStatus", value: value) + } + } +} + +/// The committed outcome of a turn handed to post-commit consumers. +public struct TurnCommit: TypraModel { + public var sessionId: String = "" + public var turnId: String = "" + public var status: EngineTurnStatus = (try! EngineTurnStatus.parse("success")) + public var output: Any? = nil + public var messages: [Message] = [] + public var iterations: Int32 = 0 + public var lastSequence: Int64 = 0 + public var contextState: InvocationContextState = InvocationContextState() + public var modelReconciliation: ModelReconciliationState? = nil + + public init( + sessionId: String = "", turnId: String = "", + status: EngineTurnStatus = (try! EngineTurnStatus.parse("success")), output: Any? = nil, + messages: [Message] = [], iterations: Int32 = 0, lastSequence: Int64 = 0, + contextState: InvocationContextState = InvocationContextState(), + modelReconciliation: ModelReconciliationState? = nil + ) { + self.sessionId = sessionId + self.turnId = turnId + self.status = status + self.output = output + self.messages = messages + self.iterations = iterations + self.lastSequence = lastSequence + self.contextState = contextState + self.modelReconciliation = modelReconciliation + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> TurnCommit { + let object = try TypraRuntime.object(data, typeName: "TurnCommit") + var instance = TurnCommit() + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["status"] { + instance.status = try EngineTurnStatus.parse(try TypraRuntime.string(value, field: "status")) + } + if let value = object["output"] { + instance.output = value + } + if let value = object["messages"] { + instance.messages = try TypraRuntime.array(value, field: "messages").map { + try Message.load($0, context: context) + } + } + if let value = object["iterations"] { + instance.iterations = try TypraRuntime.int32(value, field: "iterations") + } + if let value = object["lastSequence"] { + instance.lastSequence = try TypraRuntime.int64(value, field: "lastSequence") + } + if let value = object["contextState"] { + instance.contextState = try InvocationContextState.load(value, context: context) + } + if let value = object["modelReconciliation"] { + instance.modelReconciliation = try ModelReconciliationState.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["sessionId"] = self.sessionId + result["turnId"] = self.turnId + result["status"] = self.status.rawValue + if let value = self.output { + result["output"] = value + } + result["messages"] = try self.messages.map { try $0.save(context) } + result["iterations"] = self.iterations + result["lastSequence"] = self.lastSequence + result["contextState"] = try self.contextState.save(context) + if let value = self.modelReconciliation { + result["modelReconciliation"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TurnCommit + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "TurnCommit"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TurnCommit + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "TurnCommit"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_engine_result.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_engine_result.swift new file mode 100644 index 000000000..cb27e65b1 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_engine_result.swift @@ -0,0 +1,83 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// The result returned by the live turn engine. +public struct TurnEngineResult: TypraModel { + public var commit: TurnCommit = TurnCommit() + public var snapshots: [ModelInvocationContextSnapshot]? = nil + public var toolResults: [ModelToolResult]? = nil + public var postCommitError: String? = nil + + public init( + commit: TurnCommit = TurnCommit(), snapshots: [ModelInvocationContextSnapshot]? = nil, + toolResults: [ModelToolResult]? = nil, postCommitError: String? = nil + ) { + self.commit = commit + self.snapshots = snapshots + self.toolResults = toolResults + self.postCommitError = postCommitError + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> TurnEngineResult + { + let object = try TypraRuntime.object(data, typeName: "TurnEngineResult") + var instance = TurnEngineResult() + if let value = object["commit"] { + instance.commit = try TurnCommit.load(value, context: context) + } + if let value = object["snapshots"] { + instance.snapshots = try TypraRuntime.array(value, field: "snapshots").map { + try ModelInvocationContextSnapshot.load($0, context: context) + } + } + if let value = object["toolResults"] { + instance.toolResults = try TypraRuntime.array(value, field: "toolResults").map { + try ModelToolResult.load($0, context: context) + } + } + if let value = object["postCommitError"] { + instance.postCommitError = try TypraRuntime.string(value, field: "postCommitError") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["commit"] = try self.commit.save(context) + if let value = self.snapshots { + result["snapshots"] = try value.map { try $0.save(context) } + } + if let value = self.toolResults { + result["toolResults"] = try value.map { try $0.save(context) } + } + if let value = self.postCommitError { + result["postCommitError"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TurnEngineResult + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "TurnEngineResult"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TurnEngineResult + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "TurnEngineResult"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_model_request.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_model_request.swift new file mode 100644 index 000000000..9b8f31984 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_model_request.swift @@ -0,0 +1,93 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Request passed by the reference turn runner to the injected model callback. The runner owns deterministic orchestration semantics; model/provider-specific execution stays behind this callback boundary. +public struct TurnModelRequest: TypraModel { + public var sessionId: String = "" + public var turnId: String = "" + public var iteration: Int32 = 0 + public var inputs: [String: Any]? = nil + public var options: TurnOptions? = nil + public var toolResults: [HostToolResult]? = nil + + public init( + sessionId: String = "", turnId: String = "", iteration: Int32 = 0, inputs: [String: Any]? = nil, + options: TurnOptions? = nil, toolResults: [HostToolResult]? = nil + ) { + self.sessionId = sessionId + self.turnId = turnId + self.iteration = iteration + self.inputs = inputs + self.options = options + self.toolResults = toolResults + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> TurnModelRequest + { + let object = try TypraRuntime.object(data, typeName: "TurnModelRequest") + var instance = TurnModelRequest() + if let value = object["sessionId"] { + instance.sessionId = try TypraRuntime.string(value, field: "sessionId") + } + if let value = object["turnId"] { + instance.turnId = try TypraRuntime.string(value, field: "turnId") + } + if let value = object["iteration"] { + instance.iteration = try TypraRuntime.int32(value, field: "iteration") + } + if let value = object["inputs"] { + instance.inputs = try TypraRuntime.dictionary(value, field: "inputs") + } + if let value = object["options"] { + instance.options = try TurnOptions.load(value, context: context) + } + if let value = object["toolResults"] { + instance.toolResults = try TypraRuntime.array(value, field: "toolResults").map { + try HostToolResult.load($0, context: context) + } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["sessionId"] = self.sessionId + result["turnId"] = self.turnId + result["iteration"] = self.iteration + if let value = self.inputs { + result["inputs"] = value + } + if let value = self.options { + result["options"] = try value.save(context) + } + if let value = self.toolResults { + result["toolResults"] = try value.map { try $0.save(context) } + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TurnModelRequest + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "TurnModelRequest"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TurnModelRequest + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "TurnModelRequest"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_model_response.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_model_response.swift new file mode 100644 index 000000000..334af3d6b --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_model_response.swift @@ -0,0 +1,83 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Response returned by the injected model callback to the reference turn runner. +public struct TurnModelResponse: TypraModel { + public var output: Any? = nil + public var usage: InvocationUsage? = nil + public var toolRequests: [HostToolRequest]? = nil + public var checkpointState: [String: Any]? = nil + + public init( + output: Any? = nil, usage: InvocationUsage? = nil, toolRequests: [HostToolRequest]? = nil, + checkpointState: [String: Any]? = nil + ) { + self.output = output + self.usage = usage + self.toolRequests = toolRequests + self.checkpointState = checkpointState + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> TurnModelResponse + { + let object = try TypraRuntime.object(data, typeName: "TurnModelResponse") + var instance = TurnModelResponse() + if let value = object["output"] { + instance.output = value + } + if let value = object["usage"] { + instance.usage = try InvocationUsage.load(value, context: context) + } + if let value = object["toolRequests"] { + instance.toolRequests = try TypraRuntime.array(value, field: "toolRequests").map { + try HostToolRequest.load($0, context: context) + } + } + if let value = object["checkpointState"] { + instance.checkpointState = try TypraRuntime.dictionary(value, field: "checkpointState") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.output { + result["output"] = value + } + if let value = self.usage { + result["usage"] = try value.save(context) + } + if let value = self.toolRequests { + result["toolRequests"] = try value.map { try $0.save(context) } + } + if let value = self.checkpointState { + result["checkpointState"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TurnModelResponse + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "TurnModelResponse"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TurnModelResponse + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "TurnModelResponse"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_options.swift b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_options.swift new file mode 100644 index 000000000..fc79e3b1e --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_options.swift @@ -0,0 +1,102 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Configuration for the agent loop's turn() function. Controls iteration limits, retry policy, context management, and execution behavior. Runtimes accept these as either a TurnOptions object or individual keyword/named parameters — the TypeSpec model defines the canonical field set. +public struct TurnOptions: TypraModel { + public var maxIterations: Int32? = nil + public var maxLlmRetries: Int32? = nil + public var contextBudget: Int32? = nil + public var parallelToolCalls: Bool? = nil + public var raw: Bool? = nil + public var turn: Int32? = nil + public var compaction: CompactionConfig? = nil + + public init( + maxIterations: Int32? = nil, maxLlmRetries: Int32? = nil, contextBudget: Int32? = nil, + parallelToolCalls: Bool? = nil, raw: Bool? = nil, turn: Int32? = nil, + compaction: CompactionConfig? = nil + ) { + self.maxIterations = maxIterations + self.maxLlmRetries = maxLlmRetries + self.contextBudget = contextBudget + self.parallelToolCalls = parallelToolCalls + self.raw = raw + self.turn = turn + self.compaction = compaction + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> TurnOptions { + let object = try TypraRuntime.object(data, typeName: "TurnOptions") + var instance = TurnOptions() + if let value = object["maxIterations"] { + instance.maxIterations = try TypraRuntime.int32(value, field: "maxIterations") + } + if let value = object["maxLlmRetries"] { + instance.maxLlmRetries = try TypraRuntime.int32(value, field: "maxLlmRetries") + } + if let value = object["contextBudget"] { + instance.contextBudget = try TypraRuntime.int32(value, field: "contextBudget") + } + if let value = object["parallelToolCalls"] { + instance.parallelToolCalls = try TypraRuntime.bool(value, field: "parallelToolCalls") + } + if let value = object["raw"] { + instance.raw = try TypraRuntime.bool(value, field: "raw") + } + if let value = object["turn"] { + instance.turn = try TypraRuntime.int32(value, field: "turn") + } + if let value = object["compaction"] { + instance.compaction = try CompactionConfig.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.maxIterations { + result["maxIterations"] = value + } + if let value = self.maxLlmRetries { + result["maxLlmRetries"] = value + } + if let value = self.contextBudget { + result["contextBudget"] = value + } + if let value = self.parallelToolCalls { + result["parallelToolCalls"] = value + } + if let value = self.raw { + result["raw"] = value + } + if let value = self.turn { + result["turn"] = value + } + if let value = self.compaction { + result["compaction"] = try value.save(context) + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TurnOptions + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "TurnOptions"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TurnOptions + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "TurnOptions"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/streaming/stream_options.swift b/runtime/swift/prompty-model/Sources/PromptyModel/streaming/stream_options.swift new file mode 100644 index 000000000..ca84dcfc4 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/streaming/stream_options.swift @@ -0,0 +1,53 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Options controlling streaming behavior for LLM API calls. Passed alongside the model options when streaming is enabled. +public struct StreamOptions: TypraModel { + public var includeUsage: Bool? = nil + + public init(includeUsage: Bool? = nil) { + self.includeUsage = includeUsage + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> StreamOptions + { + let object = try TypraRuntime.object(data, typeName: "StreamOptions") + var instance = StreamOptions() + if let value = object["includeUsage"] { + instance.includeUsage = try TypraRuntime.bool(value, field: "includeUsage") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + if let value = self.includeUsage { + result["includeUsage"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> StreamOptions + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "StreamOptions"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> StreamOptions + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "StreamOptions"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/template/format_config.swift b/runtime/swift/prompty-model/Sources/PromptyModel/template/format_config.swift new file mode 100644 index 000000000..a4a75f9fe --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/template/format_config.swift @@ -0,0 +1,72 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Template format definition +public struct FormatConfig: TypraModel { + public var kind: String = "*" + public var strict: Bool? = nil + public var options: [String: Any]? = nil + + public init(kind: String = "*", strict: Bool? = nil, options: [String: Any]? = nil) { + self.kind = kind + self.strict = strict + self.options = options + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> FormatConfig + { + if let scalar = data as? String { + var instance = FormatConfig() + instance.kind = try TypraRuntime.string(scalar, field: "kind") + return instance + } + let object = try TypraRuntime.object(data, typeName: "FormatConfig") + var instance = FormatConfig() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "*" + } + if let value = object["strict"] { + instance.strict = try TypraRuntime.bool(value, field: "strict") + } + if let value = object["options"] { + instance.options = try TypraRuntime.dictionary(value, field: "options") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + if let value = self.strict { + result["strict"] = value + } + if let value = self.options { + result["options"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> FormatConfig + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "FormatConfig"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> FormatConfig + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "FormatConfig"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/template/parser_config.swift b/runtime/swift/prompty-model/Sources/PromptyModel/template/parser_config.swift new file mode 100644 index 000000000..a8b1e2def --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/template/parser_config.swift @@ -0,0 +1,64 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Template parser definition +public struct ParserConfig: TypraModel { + public var kind: String = "*" + public var options: [String: Any]? = nil + + public init(kind: String = "*", options: [String: Any]? = nil) { + self.kind = kind + self.options = options + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> ParserConfig + { + if let scalar = data as? String { + var instance = ParserConfig() + instance.kind = try TypraRuntime.string(scalar, field: "kind") + return instance + } + let object = try TypraRuntime.object(data, typeName: "ParserConfig") + var instance = ParserConfig() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "*" + } + if let value = object["options"] { + instance.options = try TypraRuntime.dictionary(value, field: "options") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + if let value = self.options { + result["options"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ParserConfig + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "ParserConfig"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ParserConfig + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "ParserConfig"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/template/template.swift b/runtime/swift/prompty-model/Sources/PromptyModel/template/template.swift new file mode 100644 index 000000000..62371d3c0 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/template/template.swift @@ -0,0 +1,54 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Template model for defining prompt templates. This model specifies the rendering engine used for slot filling prompts, the parser used to process the rendered template into API-compatible format, and additional options for the template engine. It allows for the creation of reusable templates that can be filled with dynamic data and processed to generate prompts for AI models. +public struct Template: TypraModel { + public var format: FormatConfig = FormatConfig() + public var parser: ParserConfig = ParserConfig() + + public init(format: FormatConfig = FormatConfig(), parser: ParserConfig = ParserConfig()) { + self.format = format + self.parser = parser + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> Template { + let object = try TypraRuntime.object(data, typeName: "Template") + var instance = Template() + if let value = object["format"] { + instance.format = try FormatConfig.load(value, context: context) + } + if let value = object["parser"] { + instance.parser = try ParserConfig.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["format"] = try self.format.save(context) + result["parser"] = try self.parser.save(context) + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> Template + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "Template"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> Template + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "Template"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/tools/binding.swift b/runtime/swift/prompty-model/Sources/PromptyModel/tools/binding.swift new file mode 100644 index 000000000..0fb1ad617 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/tools/binding.swift @@ -0,0 +1,61 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Represents a binding between an input property and a tool parameter. +public struct Binding: TypraModel { + public var name: String = "" + public var input: String = "" + + public init(name: String = "", input: String = "") { + self.name = name + self.input = input + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> Binding { + if let scalar = data as? String { + var instance = Binding() + instance.input = try TypraRuntime.string(scalar, field: "input") + return instance + } + let object = try TypraRuntime.object(data, typeName: "Binding") + var instance = Binding() + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } else { + instance.name = "" + } + if let value = object["input"] { + instance.input = try TypraRuntime.string(value, field: "input") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["name"] = self.name + result["input"] = self.input + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> Binding + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "Binding"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> Binding + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "Binding"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/tools/mcp_approval_mode.swift b/runtime/swift/prompty-model/Sources/PromptyModel/tools/mcp_approval_mode.swift new file mode 100644 index 000000000..10ae70f4e --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/tools/mcp_approval_mode.swift @@ -0,0 +1,94 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum McpApprovalModeKind: String, Codable, CaseIterable { + case always = "always" + case never = "never" + case specify = "specify" + public static func parse(_ value: String) throws -> McpApprovalModeKind { + switch value { + case "always": return .always + case "never": return .never + case "specify": return .specify + default: throw TypraRuntimeError.invalidEnum(type: "McpApprovalModeKind", value: value) + } + } +} + +/// The approval mode for MCP server tools. When kind is "specify", use alwaysRequireApprovalTools and neverRequireApprovalTools to control per-tool approval. For "always" and "never", those fields are ignored. +public struct McpApprovalMode: TypraModel { + public var kind: McpApprovalModeKind = (try! McpApprovalModeKind.parse("always")) + public var alwaysRequireApprovalTools: [String]? = nil + public var neverRequireApprovalTools: [String]? = nil + + public init( + kind: McpApprovalModeKind = (try! McpApprovalModeKind.parse("always")), + alwaysRequireApprovalTools: [String]? = nil, neverRequireApprovalTools: [String]? = nil + ) { + self.kind = kind + self.alwaysRequireApprovalTools = alwaysRequireApprovalTools + self.neverRequireApprovalTools = neverRequireApprovalTools + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> McpApprovalMode + { + if let scalar = data as? String { + var instance = McpApprovalMode() + instance.kind = try McpApprovalModeKind.parse(try TypraRuntime.string(scalar, field: "kind")) + return instance + } + let object = try TypraRuntime.object(data, typeName: "McpApprovalMode") + var instance = McpApprovalMode() + if let value = object["kind"] { + instance.kind = try McpApprovalModeKind.parse(try TypraRuntime.string(value, field: "kind")) + } + if let value = object["alwaysRequireApprovalTools"] { + instance.alwaysRequireApprovalTools = try TypraRuntime.array( + value, field: "alwaysRequireApprovalTools" + ).map { try TypraRuntime.string($0, field: "alwaysRequireApprovalTools") } + } + if let value = object["neverRequireApprovalTools"] { + instance.neverRequireApprovalTools = try TypraRuntime.array( + value, field: "neverRequireApprovalTools" + ).map { try TypraRuntime.string($0, field: "neverRequireApprovalTools") } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind.rawValue + if let value = self.alwaysRequireApprovalTools { + result["alwaysRequireApprovalTools"] = value + } + if let value = self.neverRequireApprovalTools { + result["neverRequireApprovalTools"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> McpApprovalMode + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "McpApprovalMode"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> McpApprovalMode + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "McpApprovalMode"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/tools/tool.swift b/runtime/swift/prompty-model/Sources/PromptyModel/tools/tool.swift new file mode 100644 index 000000000..79e99a496 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/tools/tool.swift @@ -0,0 +1,513 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +public enum Tool: TypraModel { + case functionTool(FunctionTool) + case mcpTool(McpTool) + case openApiTool(OpenApiTool) + case promptyTool(PromptyTool) + case customTool(CustomTool) + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> Tool { + let object = try TypraRuntime.object(data, typeName: "Tool") + let discriminator = try TypraRuntime.string(object["kind"] ?? "", field: "kind") + switch discriminator { + case "function": return .functionTool(try FunctionTool.load(data, context: context)) + case "mcp": return .mcpTool(try McpTool.load(data, context: context)) + case "openapi": return .openApiTool(try OpenApiTool.load(data, context: context)) + case "prompty": return .promptyTool(try PromptyTool.load(data, context: context)) + default: return .customTool(try CustomTool.load(data, context: context)) + } + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + switch self { + case .functionTool(let value): return try value.save(context) + case .mcpTool(let value): return try value.save(context) + case .openApiTool(let value): return try value.save(context) + case .promptyTool(let value): return try value.save(context) + case .customTool(let value): return try value.save(context) + } + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws -> Tool { + return try load(TypraRuntime.jsonObject(from: json, typeName: "Tool"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws -> Tool { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "Tool"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// Represents a local function tool. +public struct FunctionTool: TypraModel { + public var kind: String = "function" + public var parameters: [Property] = [] + public var strict: Bool? = nil + public var name: String = "" + public var description: String? = nil + public var bindings: [Binding]? = nil + + public init(kind: String = "function", parameters: [Property] = [], strict: Bool? = nil) { + self.kind = kind + self.parameters = parameters + self.strict = strict + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> FunctionTool + { + let object = try TypraRuntime.object(data, typeName: "FunctionTool") + var instance = FunctionTool() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "function" + } + if let value = object["parameters"] { + instance.parameters = try TypraRuntime.array(value, field: "parameters").map { + try Property.load($0, context: context) + } + } + if let value = object["strict"] { + instance.strict = try TypraRuntime.bool(value, field: "strict") + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["description"], !(value is NSNull) { + instance.description = try TypraRuntime.string(value, field: "description") + } + if let value = object["bindings"], !(value is NSNull) { + if let mapping = value as? [String: Any] { + instance.bindings = try mapping.keys.sorted().map { key in + var binding = try Binding.load(mapping[key] as Any, context: context) + binding.name = key + return binding + } + } else { + instance.bindings = try TypraRuntime.array(value, field: "bindings").map { + try Binding.load($0, context: context) + } + } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["parameters"] = try self.parameters.map { try $0.save(context) } + if let value = self.strict { + result["strict"] = value + } + if !self.name.isEmpty { result["name"] = self.name } + if let value = self.description { result["description"] = value } + if let value = self.bindings { + result["bindings"] = try value.map { try $0.save(context) } + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> FunctionTool + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "FunctionTool"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> FunctionTool + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "FunctionTool"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// Represents a generic server tool that runs on a server This tool kind is designed for operations that require server-side execution It may include features such as authentication, data storage, and long-running processes This tool kind is ideal for tasks that involve complex computations or access to secure resources Server tools can be used to offload heavy processing from client applications +public struct CustomTool: TypraModel { + public var kind: String = "*" + public var connection: Connection = .unknown([:]) + public var options: [String: Any] = [:] + public var name: String = "" + public var description: String? = nil + public var bindings: [Binding]? = nil + + public init( + kind: String = "*", connection: Connection = .unknown([:]), options: [String: Any] = [:] + ) { + self.kind = kind + self.connection = connection + self.options = options + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> CustomTool { + let object = try TypraRuntime.object(data, typeName: "CustomTool") + var instance = CustomTool() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "*" + } + if let value = object["connection"] { + instance.connection = try Connection.load(value, context: context) + } + if let value = object["options"] { + instance.options = try TypraRuntime.dictionary(value, field: "options") + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["description"], !(value is NSNull) { + instance.description = try TypraRuntime.string(value, field: "description") + } + if let value = object["bindings"], !(value is NSNull) { + if let mapping = value as? [String: Any] { + instance.bindings = try mapping.keys.sorted().map { key in + var binding = try Binding.load(mapping[key] as Any, context: context) + binding.name = key + return binding + } + } else { + instance.bindings = try TypraRuntime.array(value, field: "bindings").map { + try Binding.load($0, context: context) + } + } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["connection"] = try self.connection.save(context) + result["options"] = self.options + if !self.name.isEmpty { result["name"] = self.name } + if let value = self.description { result["description"] = value } + if let value = self.bindings { + result["bindings"] = try value.map { try $0.save(context) } + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> CustomTool + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "CustomTool"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> CustomTool + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "CustomTool"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// The MCP Server tool. +public struct McpTool: TypraModel { + public var kind: String = "mcp" + public var connection: Connection = .unknown([:]) + public var serverName: String = "" + public var serverDescription: String? = nil + public var approvalMode: McpApprovalMode = McpApprovalMode() + public var allowedTools: [String]? = nil + public var name: String = "" + public var description: String? = nil + public var bindings: [Binding]? = nil + + public init( + kind: String = "mcp", connection: Connection = .unknown([:]), serverName: String = "", + serverDescription: String? = nil, approvalMode: McpApprovalMode = McpApprovalMode(), + allowedTools: [String]? = nil + ) { + self.kind = kind + self.connection = connection + self.serverName = serverName + self.serverDescription = serverDescription + self.approvalMode = approvalMode + self.allowedTools = allowedTools + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> McpTool { + let object = try TypraRuntime.object(data, typeName: "McpTool") + var instance = McpTool() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "mcp" + } + if let value = object["connection"] { + instance.connection = try Connection.load(value, context: context) + } + if let value = object["serverName"] { + instance.serverName = try TypraRuntime.string(value, field: "serverName") + } + if let value = object["serverDescription"] { + instance.serverDescription = try TypraRuntime.string(value, field: "serverDescription") + } + if let value = object["approvalMode"] { + instance.approvalMode = try McpApprovalMode.load(value, context: context) + } + if let value = object["allowedTools"] { + instance.allowedTools = try TypraRuntime.array(value, field: "allowedTools").map { + try TypraRuntime.string($0, field: "allowedTools") + } + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["description"], !(value is NSNull) { + instance.description = try TypraRuntime.string(value, field: "description") + } + if let value = object["bindings"], !(value is NSNull) { + if let mapping = value as? [String: Any] { + instance.bindings = try mapping.keys.sorted().map { key in + var binding = try Binding.load(mapping[key] as Any, context: context) + binding.name = key + return binding + } + } else { + instance.bindings = try TypraRuntime.array(value, field: "bindings").map { + try Binding.load($0, context: context) + } + } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["connection"] = try self.connection.save(context) + result["serverName"] = self.serverName + if let value = self.serverDescription { + result["serverDescription"] = value + } + result["approvalMode"] = try self.approvalMode.save(context) + if let value = self.allowedTools { + result["allowedTools"] = value + } + if !self.name.isEmpty { result["name"] = self.name } + if let value = self.description { result["description"] = value } + if let value = self.bindings { + result["bindings"] = try value.map { try $0.save(context) } + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> McpTool + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "McpTool"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> McpTool + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "McpTool"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +public struct OpenApiTool: TypraModel { + public var kind: String = "openapi" + public var connection: Connection = .unknown([:]) + public var specification: String = "" + public var name: String = "" + public var description: String? = nil + public var bindings: [Binding]? = nil + + public init( + kind: String = "openapi", connection: Connection = .unknown([:]), specification: String = "" + ) { + self.kind = kind + self.connection = connection + self.specification = specification + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> OpenApiTool { + let object = try TypraRuntime.object(data, typeName: "OpenApiTool") + var instance = OpenApiTool() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "openapi" + } + if let value = object["connection"] { + instance.connection = try Connection.load(value, context: context) + } + if let value = object["specification"] { + instance.specification = try TypraRuntime.string(value, field: "specification") + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["description"], !(value is NSNull) { + instance.description = try TypraRuntime.string(value, field: "description") + } + if let value = object["bindings"], !(value is NSNull) { + if let mapping = value as? [String: Any] { + instance.bindings = try mapping.keys.sorted().map { key in + var binding = try Binding.load(mapping[key] as Any, context: context) + binding.name = key + return binding + } + } else { + instance.bindings = try TypraRuntime.array(value, field: "bindings").map { + try Binding.load($0, context: context) + } + } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["connection"] = try self.connection.save(context) + result["specification"] = self.specification + if !self.name.isEmpty { result["name"] = self.name } + if let value = self.description { result["description"] = value } + if let value = self.bindings { + result["bindings"] = try value.map { try $0.save(context) } + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> OpenApiTool + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "OpenApiTool"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> OpenApiTool + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "OpenApiTool"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} + +/// A tool that references another .prompty file to be invoked as a tool. The child prompty is executed as a single prompt invocation. Nested agent loops are intentionally not started from PromptyTool. +public struct PromptyTool: TypraModel { + public var kind: String = "prompty" + public var path: String = "" + public var mode: String = "single" + public var name: String = "" + public var description: String? = nil + public var bindings: [Binding]? = nil + + public init(kind: String = "prompty", path: String = "", mode: String = "single") { + self.kind = kind + self.path = path + self.mode = mode + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> PromptyTool { + let object = try TypraRuntime.object(data, typeName: "PromptyTool") + var instance = PromptyTool() + if let value = object["kind"] { + instance.kind = try TypraRuntime.string(value, field: "kind") + } else { + instance.kind = "prompty" + } + if let value = object["path"] { + instance.path = try TypraRuntime.string(value, field: "path") + } + if let value = object["mode"] { + instance.mode = try TypraRuntime.string(value, field: "mode") + } else { + instance.mode = "single" + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["description"], !(value is NSNull) { + instance.description = try TypraRuntime.string(value, field: "description") + } + if let value = object["bindings"], !(value is NSNull) { + if let mapping = value as? [String: Any] { + instance.bindings = try mapping.keys.sorted().map { key in + var binding = try Binding.load(mapping[key] as Any, context: context) + binding.name = key + return binding + } + } else { + instance.bindings = try TypraRuntime.array(value, field: "bindings").map { + try Binding.load($0, context: context) + } + } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["kind"] = self.kind + result["path"] = self.path + result["mode"] = self.mode + if !self.name.isEmpty { result["name"] = self.name } + if let value = self.description { result["description"] = value } + if let value = self.bindings { + result["bindings"] = try value.map { try $0.save(context) } + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> PromptyTool + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "PromptyTool"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> PromptyTool + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "PromptyTool"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/tools/tool_context.swift b/runtime/swift/prompty-model/Sources/PromptyModel/tools/tool_context.swift new file mode 100644 index 000000000..2365f934f --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/tools/tool_context.swift @@ -0,0 +1,58 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Context passed to tool handlers during agent loop execution. Provides access to the agent configuration, current conversation state, and arbitrary metadata for tool implementations that need broader context. +public struct ToolContext: TypraModel { + public var messages: [Message] = [] + public var metadata: [String: Any]? = nil + + public init(messages: [Message] = [], metadata: [String: Any]? = nil) { + self.messages = messages + self.metadata = metadata + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> ToolContext { + let object = try TypraRuntime.object(data, typeName: "ToolContext") + var instance = ToolContext() + if let value = object["messages"] { + instance.messages = try TypraRuntime.array(value, field: "messages").map { + try Message.load($0, context: context) + } + } + if let value = object["metadata"] { + instance.metadata = try TypraRuntime.dictionary(value, field: "metadata") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["messages"] = try self.messages.map { try $0.save(context) } + if let value = self.metadata { + result["metadata"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ToolContext + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "ToolContext"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ToolContext + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "ToolContext"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/tools/tool_dispatch_result.swift b/runtime/swift/prompty-model/Sources/PromptyModel/tools/tool_dispatch_result.swift new file mode 100644 index 000000000..b7a9b7f27 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/tools/tool_dispatch_result.swift @@ -0,0 +1,64 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// The result of dispatching a single tool call. Pairs the tool call identifier with the tool's name and result for correlation in the agent loop's message assembly. +public struct ToolDispatchResult: TypraModel { + public var toolCallId: String = "" + public var name: String = "" + public var result: ToolResult = ToolResult() + + public init(toolCallId: String = "", name: String = "", result: ToolResult = ToolResult()) { + self.toolCallId = toolCallId + self.name = name + self.result = result + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> ToolDispatchResult + { + let object = try TypraRuntime.object(data, typeName: "ToolDispatchResult") + var instance = ToolDispatchResult() + if let value = object["toolCallId"] { + instance.toolCallId = try TypraRuntime.string(value, field: "toolCallId") + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["result"] { + instance.result = try ToolResult.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["toolCallId"] = self.toolCallId + result["name"] = self.name + result["result"] = try self.result.save(context) + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> ToolDispatchResult + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "ToolDispatchResult"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> ToolDispatchResult + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "ToolDispatchResult"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/tracing/trace_file.swift b/runtime/swift/prompty-model/Sources/PromptyModel/tracing/trace_file.swift new file mode 100644 index 000000000..02a393d8b --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/tracing/trace_file.swift @@ -0,0 +1,60 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// The top-level .tracy file structure written by the file backend (§3.6.1). +public struct TraceFile: TypraModel { + public var runtime: String = "" + public var version: String = "" + public var trace: TraceSpan = TraceSpan() + + public init(runtime: String = "", version: String = "", trace: TraceSpan = TraceSpan()) { + self.runtime = runtime + self.version = version + self.trace = trace + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> TraceFile { + let object = try TypraRuntime.object(data, typeName: "TraceFile") + var instance = TraceFile() + if let value = object["runtime"] { + instance.runtime = try TypraRuntime.string(value, field: "runtime") + } + if let value = object["version"] { + instance.version = try TypraRuntime.string(value, field: "version") + } + if let value = object["trace"] { + instance.trace = try TraceSpan.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["runtime"] = self.runtime + result["version"] = self.version + result["trace"] = try self.trace.save(context) + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TraceFile + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "TraceFile"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TraceFile + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "TraceFile"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/tracing/trace_span.swift b/runtime/swift/prompty-model/Sources/PromptyModel/tracing/trace_span.swift new file mode 100644 index 000000000..86581d327 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/tracing/trace_span.swift @@ -0,0 +1,114 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A single trace span capturing one pipeline stage or function invocation. Spans nest via the `__frames` field to form a tree representing the full execution (§3.6.1). +public struct TraceSpan: TypraModel { + public var name: String = "" + public var time: TraceTime = TraceTime() + public var signature: String? = nil + public var inputs: [String: Any]? = nil + public var output: Any? = nil + public var error: String? = nil + public var usage: TokenUsage? = nil + public var attributes: [String: Any]? = nil + public var frames: [Any]? = nil + + public init( + name: String = "", time: TraceTime = TraceTime(), signature: String? = nil, + inputs: [String: Any]? = nil, output: Any? = nil, error: String? = nil, + usage: TokenUsage? = nil, attributes: [String: Any]? = nil, frames: [Any]? = nil + ) { + self.name = name + self.time = time + self.signature = signature + self.inputs = inputs + self.output = output + self.error = error + self.usage = usage + self.attributes = attributes + self.frames = frames + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> TraceSpan { + let object = try TypraRuntime.object(data, typeName: "TraceSpan") + var instance = TraceSpan() + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["__time"] { + instance.time = try TraceTime.load(value, context: context) + } + if let value = object["signature"] { + instance.signature = try TypraRuntime.string(value, field: "signature") + } + if let value = object["inputs"] { + instance.inputs = try TypraRuntime.dictionary(value, field: "inputs") + } + if let value = object["output"] { + instance.output = value + } + if let value = object["error"] { + instance.error = try TypraRuntime.string(value, field: "error") + } + if let value = object["__usage"] { + instance.usage = try TokenUsage.load(value, context: context) + } + if let value = object["attributes"] { + instance.attributes = try TypraRuntime.dictionary(value, field: "attributes") + } + if let value = object["__frames"] { + instance.frames = try TypraRuntime.array(value, field: "__frames").map { $0 } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["name"] = self.name + result["__time"] = try self.time.save(context) + if let value = self.signature { + result["signature"] = value + } + if let value = self.inputs { + result["inputs"] = value + } + if let value = self.output { + result["output"] = value + } + if let value = self.error { + result["error"] = value + } + if let value = self.usage { + result["__usage"] = try value.save(context) + } + if let value = self.attributes { + result["attributes"] = value + } + if let value = self.frames { + result["__frames"] = value + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TraceSpan + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "TraceSpan"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TraceSpan + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "TraceSpan"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/tracing/trace_time.swift b/runtime/swift/prompty-model/Sources/PromptyModel/tracing/trace_time.swift new file mode 100644 index 000000000..4118497b4 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/tracing/trace_time.swift @@ -0,0 +1,60 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Timing information for a trace span. +public struct TraceTime: TypraModel { + public var start: String = "" + public var end: String = "" + public var duration: Double = 0 + + public init(start: String = "", end: String = "", duration: Double = 0) { + self.start = start + self.end = end + self.duration = duration + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws -> TraceTime { + let object = try TypraRuntime.object(data, typeName: "TraceTime") + var instance = TraceTime() + if let value = object["start"] { + instance.start = try TypraRuntime.string(value, field: "start") + } + if let value = object["end"] { + instance.end = try TypraRuntime.string(value, field: "end") + } + if let value = object["duration"] { + instance.duration = try TypraRuntime.double(value, field: "duration") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["start"] = self.start + result["end"] = self.end + result["duration"] = self.duration + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> TraceTime + { + return try load(TypraRuntime.jsonObject(from: json, typeName: "TraceTime"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> TraceTime + { + return try load(TypraRuntime.yamlObject(from: yaml, typeName: "TraceTime"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_image_block.swift b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_image_block.swift new file mode 100644 index 000000000..c8bf69b11 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_image_block.swift @@ -0,0 +1,60 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// An image content block using base64-encoded data. Anthropic requires images as base64 with an explicit media type. +public struct AnthropicImageBlock: TypraModel { + public var type: String = "image" + public var source: AnthropicImageSource = AnthropicImageSource() + + public init(type: String = "image", source: AnthropicImageSource = AnthropicImageSource()) { + self.type = type + self.source = source + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> AnthropicImageBlock + { + let object = try TypraRuntime.object(data, typeName: "AnthropicImageBlock") + var instance = AnthropicImageBlock() + if let value = object["type"] { + instance.type = try TypraRuntime.string(value, field: "type") + } else { + instance.type = "image" + } + if let value = object["source"] { + instance.source = try AnthropicImageSource.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["type"] = self.type + result["source"] = try self.source.save(context) + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> AnthropicImageBlock + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "AnthropicImageBlock"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> AnthropicImageBlock + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "AnthropicImageBlock"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_image_source.swift b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_image_source.swift new file mode 100644 index 000000000..f7f07c0dd --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_image_source.swift @@ -0,0 +1,66 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Source descriptor for an Anthropic base64 image. +public struct AnthropicImageSource: TypraModel { + public var type: String = "base64" + public var mediaType: String = "" + public var data: String = "" + + public init(type: String = "base64", mediaType: String = "", data: String = "") { + self.type = type + self.mediaType = mediaType + self.data = data + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> AnthropicImageSource + { + let object = try TypraRuntime.object(data, typeName: "AnthropicImageSource") + var instance = AnthropicImageSource() + if let value = object["type"] { + instance.type = try TypraRuntime.string(value, field: "type") + } else { + instance.type = "base64" + } + if let value = object["media_type"] { + instance.mediaType = try TypraRuntime.string(value, field: "media_type") + } + if let value = object["data"] { + instance.data = try TypraRuntime.string(value, field: "data") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["type"] = self.type + result["media_type"] = self.mediaType + result["data"] = self.data + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> AnthropicImageSource + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "AnthropicImageSource"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> AnthropicImageSource + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "AnthropicImageSource"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_messages_request.swift b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_messages_request.swift new file mode 100644 index 000000000..81cb4245e --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_messages_request.swift @@ -0,0 +1,122 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// The full request body for the Anthropic Messages API (§7.5). +public struct AnthropicMessagesRequest: TypraModel { + public var model: String = "" + public var messages: [AnthropicWireMessage] = [] + public var maxTokens: Int32 = 0 + public var system: String? = nil + public var temperature: Float? = nil + public var topP: Float? = nil + public var topK: Int32? = nil + public var stopSequences: [String]? = nil + public var tools: [AnthropicToolDefinition]? = nil + + public init( + model: String = "", messages: [AnthropicWireMessage] = [], maxTokens: Int32 = 0, + system: String? = nil, temperature: Float? = nil, topP: Float? = nil, topK: Int32? = nil, + stopSequences: [String]? = nil, tools: [AnthropicToolDefinition]? = nil + ) { + self.model = model + self.messages = messages + self.maxTokens = maxTokens + self.system = system + self.temperature = temperature + self.topP = topP + self.topK = topK + self.stopSequences = stopSequences + self.tools = tools + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> AnthropicMessagesRequest + { + let object = try TypraRuntime.object(data, typeName: "AnthropicMessagesRequest") + var instance = AnthropicMessagesRequest() + if let value = object["model"] { + instance.model = try TypraRuntime.string(value, field: "model") + } + if let value = object["messages"] { + instance.messages = try TypraRuntime.array(value, field: "messages").map { + try AnthropicWireMessage.load($0, context: context) + } + } + if let value = object["max_tokens"] { + instance.maxTokens = try TypraRuntime.int32(value, field: "max_tokens") + } + if let value = object["system"] { + instance.system = try TypraRuntime.string(value, field: "system") + } + if let value = object["temperature"] { + instance.temperature = try TypraRuntime.float(value, field: "temperature") + } + if let value = object["top_p"] { + instance.topP = try TypraRuntime.float(value, field: "top_p") + } + if let value = object["top_k"] { + instance.topK = try TypraRuntime.int32(value, field: "top_k") + } + if let value = object["stop_sequences"] { + instance.stopSequences = try TypraRuntime.array(value, field: "stop_sequences").map { + try TypraRuntime.string($0, field: "stop_sequences") + } + } + if let value = object["tools"] { + instance.tools = try TypraRuntime.array(value, field: "tools").map { + try AnthropicToolDefinition.load($0, context: context) + } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["model"] = self.model + result["messages"] = try self.messages.map { try $0.save(context) } + result["max_tokens"] = self.maxTokens + if let value = self.system { + result["system"] = value + } + if let value = self.temperature { + result["temperature"] = value + } + if let value = self.topP { + result["top_p"] = value + } + if let value = self.topK { + result["top_k"] = value + } + if let value = self.stopSequences { + result["stop_sequences"] = value + } + if let value = self.tools { + result["tools"] = try value.map { try $0.save(context) } + } + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> AnthropicMessagesRequest + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "AnthropicMessagesRequest"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> AnthropicMessagesRequest + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "AnthropicMessagesRequest"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_messages_response.swift b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_messages_response.swift new file mode 100644 index 000000000..92e65c691 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_messages_response.swift @@ -0,0 +1,95 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// The response body from the Anthropic Messages API. +public struct AnthropicMessagesResponse: TypraModel { + public var id: String = "" + public var type: String = "message" + public var role: String = "assistant" + public var content: [Any] = [] + public var model: String = "" + public var stopReason: String = "" + public var usage: AnthropicUsage = AnthropicUsage() + + public init( + id: String = "", type: String = "message", role: String = "assistant", content: [Any] = [], + model: String = "", stopReason: String = "", usage: AnthropicUsage = AnthropicUsage() + ) { + self.id = id + self.type = type + self.role = role + self.content = content + self.model = model + self.stopReason = stopReason + self.usage = usage + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> AnthropicMessagesResponse + { + let object = try TypraRuntime.object(data, typeName: "AnthropicMessagesResponse") + var instance = AnthropicMessagesResponse() + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["type"] { + instance.type = try TypraRuntime.string(value, field: "type") + } else { + instance.type = "message" + } + if let value = object["role"] { + instance.role = try TypraRuntime.string(value, field: "role") + } else { + instance.role = "assistant" + } + if let value = object["content"] { + instance.content = try TypraRuntime.array(value, field: "content").map { $0 } + } + if let value = object["model"] { + instance.model = try TypraRuntime.string(value, field: "model") + } + if let value = object["stop_reason"] { + instance.stopReason = try TypraRuntime.string(value, field: "stop_reason") + } + if let value = object["usage"] { + instance.usage = try AnthropicUsage.load(value, context: context) + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["id"] = self.id + result["type"] = self.type + result["role"] = self.role + result["content"] = self.content + result["model"] = self.model + result["stop_reason"] = self.stopReason + result["usage"] = try self.usage.save(context) + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> AnthropicMessagesResponse + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "AnthropicMessagesResponse"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> AnthropicMessagesResponse + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "AnthropicMessagesResponse"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_text_block.swift b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_text_block.swift new file mode 100644 index 000000000..301b02175 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_text_block.swift @@ -0,0 +1,60 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A text content block in Anthropic's array-of-blocks message format. +public struct AnthropicTextBlock: TypraModel { + public var type: String = "text" + public var text: String = "" + + public init(type: String = "text", text: String = "") { + self.type = type + self.text = text + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> AnthropicTextBlock + { + let object = try TypraRuntime.object(data, typeName: "AnthropicTextBlock") + var instance = AnthropicTextBlock() + if let value = object["type"] { + instance.type = try TypraRuntime.string(value, field: "type") + } else { + instance.type = "text" + } + if let value = object["text"] { + instance.text = try TypraRuntime.string(value, field: "text") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["type"] = self.type + result["text"] = self.text + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> AnthropicTextBlock + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "AnthropicTextBlock"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> AnthropicTextBlock + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "AnthropicTextBlock"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_tool_definition.swift b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_tool_definition.swift new file mode 100644 index 000000000..a01ea6afc --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_tool_definition.swift @@ -0,0 +1,66 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A tool definition in Anthropic's format. Unlike OpenAI which wraps tools in `{type: "function", function: {...}}`, Anthropic uses a flat structure with `input_schema` (§7.5). +public struct AnthropicToolDefinition: TypraModel { + public var name: String = "" + public var description: String? = nil + public var inputSchema: [String: Any] = [:] + + public init(name: String = "", description: String? = nil, inputSchema: [String: Any] = [:]) { + self.name = name + self.description = description + self.inputSchema = inputSchema + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> AnthropicToolDefinition + { + let object = try TypraRuntime.object(data, typeName: "AnthropicToolDefinition") + var instance = AnthropicToolDefinition() + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["description"] { + instance.description = try TypraRuntime.string(value, field: "description") + } + if let value = object["input_schema"] { + instance.inputSchema = try TypraRuntime.dictionary(value, field: "input_schema") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["name"] = self.name + if let value = self.description { + result["description"] = value + } + result["input_schema"] = self.inputSchema + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> AnthropicToolDefinition + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "AnthropicToolDefinition"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> AnthropicToolDefinition + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "AnthropicToolDefinition"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_tool_result_block.swift b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_tool_result_block.swift new file mode 100644 index 000000000..426b2d75b --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_tool_result_block.swift @@ -0,0 +1,66 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A tool result content block sent back to the API with the tool's output. +public struct AnthropicToolResultBlock: TypraModel { + public var type: String = "tool_result" + public var toolUseId: String = "" + public var content: String = "" + + public init(type: String = "tool_result", toolUseId: String = "", content: String = "") { + self.type = type + self.toolUseId = toolUseId + self.content = content + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> AnthropicToolResultBlock + { + let object = try TypraRuntime.object(data, typeName: "AnthropicToolResultBlock") + var instance = AnthropicToolResultBlock() + if let value = object["type"] { + instance.type = try TypraRuntime.string(value, field: "type") + } else { + instance.type = "tool_result" + } + if let value = object["tool_use_id"] { + instance.toolUseId = try TypraRuntime.string(value, field: "tool_use_id") + } + if let value = object["content"] { + instance.content = try TypraRuntime.string(value, field: "content") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["type"] = self.type + result["tool_use_id"] = self.toolUseId + result["content"] = self.content + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> AnthropicToolResultBlock + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "AnthropicToolResultBlock"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> AnthropicToolResultBlock + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "AnthropicToolResultBlock"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_tool_use_block.swift b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_tool_use_block.swift new file mode 100644 index 000000000..749d73999 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_tool_use_block.swift @@ -0,0 +1,74 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A tool use content block returned in an assistant message when the model wants to invoke a tool. +public struct AnthropicToolUseBlock: TypraModel { + public var type: String = "tool_use" + public var id: String = "" + public var name: String = "" + public var input: [String: Any] = [:] + + public init( + type: String = "tool_use", id: String = "", name: String = "", input: [String: Any] = [:] + ) { + self.type = type + self.id = id + self.name = name + self.input = input + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> AnthropicToolUseBlock + { + let object = try TypraRuntime.object(data, typeName: "AnthropicToolUseBlock") + var instance = AnthropicToolUseBlock() + if let value = object["type"] { + instance.type = try TypraRuntime.string(value, field: "type") + } else { + instance.type = "tool_use" + } + if let value = object["id"] { + instance.id = try TypraRuntime.string(value, field: "id") + } + if let value = object["name"] { + instance.name = try TypraRuntime.string(value, field: "name") + } + if let value = object["input"] { + instance.input = try TypraRuntime.dictionary(value, field: "input") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["type"] = self.type + result["id"] = self.id + result["name"] = self.name + result["input"] = self.input + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> AnthropicToolUseBlock + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "AnthropicToolUseBlock"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> AnthropicToolUseBlock + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "AnthropicToolUseBlock"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_usage.swift b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_usage.swift new file mode 100644 index 000000000..f243cf27a --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_usage.swift @@ -0,0 +1,58 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// Usage statistics returned in an Anthropic Messages API response. +public struct AnthropicUsage: TypraModel { + public var inputTokens: Int32 = 0 + public var outputTokens: Int32 = 0 + + public init(inputTokens: Int32 = 0, outputTokens: Int32 = 0) { + self.inputTokens = inputTokens + self.outputTokens = outputTokens + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> AnthropicUsage + { + let object = try TypraRuntime.object(data, typeName: "AnthropicUsage") + var instance = AnthropicUsage() + if let value = object["input_tokens"] { + instance.inputTokens = try TypraRuntime.int32(value, field: "input_tokens") + } + if let value = object["output_tokens"] { + instance.outputTokens = try TypraRuntime.int32(value, field: "output_tokens") + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["input_tokens"] = self.inputTokens + result["output_tokens"] = self.outputTokens + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> AnthropicUsage + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "AnthropicUsage"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> AnthropicUsage + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "AnthropicUsage"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_wire_message.swift b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_wire_message.swift new file mode 100644 index 000000000..e09770149 --- /dev/null +++ b/runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_wire_message.swift @@ -0,0 +1,58 @@ +// +// Code generated by Typra emitter; DO NOT EDIT. + +import Foundation + +/// A single message in the Anthropic Messages API wire format. Anthropic always uses the array-of-blocks form for content, even when there is only one text block (§7.5). +public struct AnthropicWireMessage: TypraModel { + public var role: String = "" + public var content: [Any] = [] + + public init(role: String = "", content: [Any] = []) { + self.role = role + self.content = content + } + + public static func load(_ data: Any, context: LoadContext = LoadContext()) throws + -> AnthropicWireMessage + { + let object = try TypraRuntime.object(data, typeName: "AnthropicWireMessage") + var instance = AnthropicWireMessage() + if let value = object["role"] { + instance.role = try TypraRuntime.string(value, field: "role") + } + if let value = object["content"] { + instance.content = try TypraRuntime.array(value, field: "content").map { $0 } + } + return instance + } + + public func save(_ context: SaveContext = SaveContext()) throws -> [String: Any] { + var result: [String: Any] = [:] + result["role"] = self.role + result["content"] = self.content + return result + } + + public static func fromJSON(_ json: String, context: LoadContext = LoadContext()) throws + -> AnthropicWireMessage + { + return try load( + TypraRuntime.jsonObject(from: json, typeName: "AnthropicWireMessage"), context: context) + } + + public func toJSON(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.jsonString(from: save(context)) + } + + public static func fromYAML(_ yaml: String, context: LoadContext = LoadContext()) throws + -> AnthropicWireMessage + { + return try load( + TypraRuntime.yamlObject(from: yaml, typeName: "AnthropicWireMessage"), context: context) + } + + public func toYAML(_ context: SaveContext = SaveContext()) throws -> String { + return try TypraRuntime.yamlString(from: save(context)) + } +} diff --git a/runtime/swift/prompty/Package.swift b/runtime/swift/prompty/Package.swift new file mode 100644 index 000000000..a9ba41f70 --- /dev/null +++ b/runtime/swift/prompty/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "Prompty", + platforms: [.macOS(.v12), .iOS(.v15)], + products: [ + .library(name: "Prompty", targets: ["Prompty"]), + .library(name: "PromptyOpenAI", targets: ["PromptyOpenAI"]), + ], + dependencies: [ + .package(path: "../prompty-model"), + .package(url: "https://github.com/jpsim/Yams.git", from: "5.1.3"), + ], + targets: [ + .target( + name: "Prompty", + dependencies: [ + .product(name: "PromptyModel", package: "prompty-model"), + .product(name: "Yams", package: "Yams"), + ] + ), + .target( + name: "PromptyOpenAI", + dependencies: ["Prompty", .product(name: "PromptyModel", package: "prompty-model")] + ), + .testTarget( + name: "PromptyTests", + dependencies: [ + "Prompty", "PromptyOpenAI", + .product(name: "PromptyModel", package: "prompty-model"), + .product(name: "Yams", package: "Yams"), + ] + ), + ] +) diff --git a/runtime/swift/prompty/Sources/Prompty/Defaults.swift b/runtime/swift/prompty/Sources/Prompty/Defaults.swift new file mode 100644 index 000000000..5b976a477 --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/Defaults.swift @@ -0,0 +1,30 @@ +/// Cross-runtime constants. These values are part of the Prompty spec and must +/// match every other runtime exactly. +public enum Defaults { + /// Renderer key used when `template.format.kind` is absent or empty. + public static let templateFormat = "jinja2" + + /// Parser key used when `template.parser.kind` is absent or empty. + public static let parser = "prompty" + + /// Provider key used when `model.provider` is absent or empty. + public static let provider = "openai" + + /// Injected discriminator — a `.prompty` file is always a prompt. + public static let kind = "prompt" + + /// Metadata key holding the file a prompt was loaded from. + public static let sourcePathKey = "__source_path" + + /// Marker key identifying a wrapped structured result. + public static let structuredMarker = "__prompty_structured" + + /// Prefix of a rich-input nonce placeholder. + public static let threadNoncePrefix = "__PROMPTY_THREAD_" + + /// Input kinds replaced by nonce placeholders during rendering. + public static let richKinds: Set = ["thread", "image", "file", "audio"] + + /// Role markers the Prompty parser recognizes. + public static let roleMarkers: Set = ["system", "user", "assistant"] +} diff --git a/runtime/swift/prompty/Sources/Prompty/Errors.swift b/runtime/swift/prompty/Sources/Prompty/Errors.swift new file mode 100644 index 000000000..9749c32f0 --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/Errors.swift @@ -0,0 +1,65 @@ +/// Errors raised while loading a `.prompty` file. +/// +/// Message formats are shared across every Prompty runtime so that host +/// applications can match on them consistently. + +/// Errors raised while resolving or running a pipeline stage. +public enum LoadError: Error, CustomStringConvertible, Equatable { + /// The `.prompty` file could not be read. + case fileNotFound(path: String, detail: String) + /// The YAML frontmatter was absent, unterminated, or not a mapping. + case invalidFrontmatter(String) + /// A `${env:VAR}` reference had no value and no default. + case envVarNotSet(varName: String, key: String) + /// A `${file:path}` reference could not be read, parsed, or was out of bounds. + case fileReference(path: String, detail: String) + /// The frontmatter did not satisfy the Prompty model schema. + case invalidModel(String) + /// A name-keyed collection (`inputs`, `outputs`, nested `properties`) held a + /// value that can never be a property. `path` is the full dotted path to the + /// offending entry and `valueCategory` names the rejected shape. + case invalidNamedCollectionEntry(path: String, valueCategory: String) + + public var description: String { + switch self { + case .fileNotFound(let path, let detail): + return "File not found: \(path): \(detail)" + case .invalidFrontmatter(let message): + return "Invalid frontmatter: \(message)" + case .envVarNotSet(let varName, let key): + return "Environment variable '\(varName)' not set for key '\(key)'" + case .fileReference(let path, let detail): + return "File reference error: \(path): \(detail)" + case .invalidModel(let message): + return "Invalid prompty model: \(message)" + case .invalidNamedCollectionEntry(let path, let valueCategory): + return + "invalid-named-collection-entry: \(path) holds a \(valueCategory), " + + "which is not a valid named collection entry. Declare the array inside " + + "a property instead, for example `\(path): { kind: array, default: [...] }`" + } + } +} +public enum InvokerError: Error, CustomStringConvertible, Equatable { + /// No implementation is registered under `key` for the given `group`. + case notFound(group: String, key: String) + /// The parser rejected the rendered text. + case parse(String) + /// A required input was missing, or an input failed validation. + case validation(String) + /// A provider call failed. + case execution(String) + /// A response could not be turned into a result. + case processing(String) + + public var description: String { + switch self { + case .notFound(let group, let key): + return "no \(group) registered for key '\(key)'" + case .parse(let message): return message + case .validation(let message): return message + case .execution(let message): return message + case .processing(let message): return message + } + } +} diff --git a/runtime/swift/prompty/Sources/Prompty/ExpressionParser.swift b/runtime/swift/prompty/Sources/Prompty/ExpressionParser.swift new file mode 100644 index 000000000..e1f0474e9 --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/ExpressionParser.swift @@ -0,0 +1,495 @@ +/// Evaluates the expression grammar used inside `{{ … }}` and `{% if … %}`. +/// +/// Prompt templates routinely branch on comparisons (`{% if score > 3 %}`), +/// membership (`{% if role in allowed %}`) and negation (`{% if not draft %}`). +/// Treating those as opaque variable names resolves them to `nil`, which is +/// falsy — so the template silently renders the wrong branch instead of +/// failing. This parser exists to make that class of bug impossible: anything +/// it cannot evaluate is rejected loudly. +/// +/// Precedence, lowest to highest, follows Jinja: +/// `or` → `and` → `not` → comparison / `in` / `is` → `~` → `+ -` → `* / // %` +/// → unary `-` → filters, member access, indexing. +import Foundation + +enum ExpressionParser { + + static func evaluate( + _ source: String, + scope: [String: Any], + filter: @escaping (String, [Any?], Any?) throws -> Any? + ) throws -> Any? { + var parser = Parser(source: source, scope: scope, filter: filter) + let value = try parser.parseExpression() + try parser.expectEnd() + return value + } + + // MARK: - Tokenizer + + enum Token: Equatable { + case identifier(String) + case string(String) + case number(Double, isInteger: Bool) + case symbol(String) + } + + static func tokenize(_ source: String) throws -> [Token] { + var tokens: [Token] = [] + let characters = Array(source) + var index = 0 + + // Multi-character operators must be matched before their prefixes, or + // `<=` would tokenize as `<` followed by `=`. + let operators = [ + "//", "==", "!=", "<=", ">=", "**", "|", "(", ")", "[", "]", ",", ".", + "+", "-", "*", "/", "%", "<", ">", "~", + ] + + while index < characters.count { + let character = characters[index] + + if character.isWhitespace { + index += 1 + continue + } + + if character == "\"" || character == "'" { + let quote = character + index += 1 + var value = "" + while index < characters.count, characters[index] != quote { + // Backslash escapes keep quoted punctuation out of the operator path. + if characters[index] == "\\", index + 1 < characters.count { + index += 1 + } + value.append(characters[index]) + index += 1 + } + guard index < characters.count else { + throw InvokerError.parse("unterminated string in expression '\(source)'") + } + index += 1 + tokens.append(.string(value)) + continue + } + + if character.isNumber { + var text = "" + var isInteger = true + while index < characters.count, characters[index].isNumber || characters[index] == "." { + // A trailing dot is member access on a number literal, not a decimal + // point, so only consume it when a digit follows. + if characters[index] == "." { + guard isInteger, index + 1 < characters.count, characters[index + 1].isNumber else { + break + } + isInteger = false + } + text.append(characters[index]) + index += 1 + } + guard let number = Double(text) else { + throw InvokerError.parse("invalid number '\(text)' in expression '\(source)'") + } + tokens.append(.number(number, isInteger: isInteger)) + continue + } + + if character.isLetter || character == "_" { + var text = "" + while index < characters.count, + characters[index].isLetter || characters[index].isNumber || characters[index] == "_" + { + text.append(characters[index]) + index += 1 + } + tokens.append(.identifier(text)) + continue + } + + if let match = operators.first(where: { matches($0, characters, at: index) }) { + tokens.append(.symbol(match)) + index += match.count + continue + } + + throw InvokerError.parse("unexpected character '\(character)' in expression '\(source)'") + } + + return tokens + } + + private static func matches(_ candidate: String, _ characters: [Character], at index: Int) -> Bool + { + let symbol = Array(candidate) + guard index + symbol.count <= characters.count else { return false } + for offset in 0.. Any? + var position = 0 + + init( + source: String, scope: [String: Any], + filter: @escaping (String, [Any?], Any?) throws -> Any? + ) { + self.tokens = (try? ExpressionParser.tokenize(source)) ?? [] + self.source = source + self.scope = scope + self.filter = filter + } + + var current: Token? { position < tokens.count ? tokens[position] : nil } + + mutating func expectEnd() throws { + guard position >= tokens.count else { + throw InvokerError.parse("unparsed trailing input in expression '\(source)'") + } + } + + mutating func consume(symbol: String) -> Bool { + guard current == .symbol(symbol) else { return false } + position += 1 + return true + } + + mutating func consume(identifier: String) -> Bool { + guard current == .identifier(identifier) else { return false } + position += 1 + return true + } + + // MARK: Precedence levels + + mutating func parseExpression() throws -> Any? { try parseOr() } + + mutating func parseOr() throws -> Any? { + var left = try parseAnd() + while consume(identifier: "or") { + let right = try parseAnd() + // Jinja's `or` yields the operand, not a boolean. + left = JSONSupport.isTruthy(left) ? left : right + } + return left + } + + mutating func parseAnd() throws -> Any? { + var left = try parseNot() + while consume(identifier: "and") { + let right = try parseNot() + left = JSONSupport.isTruthy(left) ? right : left + } + return left + } + + mutating func parseNot() throws -> Any? { + if consume(identifier: "not") { + return !JSONSupport.isTruthy(try parseNot()) + } + return try parseComparison() + } + + mutating func parseComparison() throws -> Any? { + let left = try parseConcat() + + if consume(identifier: "is") { + let negated = consume(identifier: "not") + guard case .identifier(let test)? = current else { + throw InvokerError.parse("expected a test after 'is' in '\(source)'") + } + position += 1 + let result = try applyTest(test, to: left) + return negated ? !result : result + } + + if consume(identifier: "in") { + return contains(try parseConcat(), left) + } + if consume(identifier: "not") { + guard consume(identifier: "in") else { + throw InvokerError.parse("expected 'in' after 'not' in '\(source)'") + } + return !contains(try parseConcat(), left) + } + + for symbol in ["==", "!=", "<=", ">=", "<", ">"] where consume(symbol: symbol) { + return try compare(symbol, left, try parseConcat()) + } + return left + } + + mutating func parseConcat() throws -> Any? { + var left = try parseAdditive() + while consume(symbol: "~") { + left = JSONSupport.stringify(left) + JSONSupport.stringify(try parseAdditive()) + } + return left + } + + mutating func parseAdditive() throws -> Any? { + var left = try parseMultiplicative() + while true { + if consume(symbol: "+") { + let right = try parseMultiplicative() + // `+` concatenates when either side is a string, matching Jinja. + if left is String || right is String { + left = JSONSupport.stringify(left) + JSONSupport.stringify(right) + } else if let a = left as? [Any], let b = right as? [Any] { + left = a + b + } else { + left = try arithmetic("+", left, right) + } + } else if consume(symbol: "-") { + left = try arithmetic("-", left, try parseMultiplicative()) + } else { + return left + } + } + } + + mutating func parseMultiplicative() throws -> Any? { + var left = try parseUnary() + while true { + if let symbol = ["*", "//", "/", "%"].first(where: { current == .symbol($0) }) { + position += 1 + left = try arithmetic(symbol, left, try parseUnary()) + } else { + return left + } + } + } + + mutating func parseUnary() throws -> Any? { + if consume(symbol: "-") { + return try arithmetic("-", 0, try parseUnary()) + } + if consume(symbol: "+") { + return try parseUnary() + } + return try parsePostfix() + } + + /// Member access, indexing, and filters all bind tighter than operators. + mutating func parsePostfix() throws -> Any? { + var value = try parsePrimary() + + while let token = current { + if token == .symbol(".") { + position += 1 + guard case .identifier(let name)? = current else { + throw InvokerError.parse("expected a property name after '.' in '\(source)'") + } + position += 1 + value = member(name, of: value) + } else if token == .symbol("[") { + position += 1 + let index = try parseExpression() + guard consume(symbol: "]") else { + throw InvokerError.parse("expected ']' in '\(source)'") + } + value = subscriptValue(value, by: index) + } else if token == .symbol("|") { + position += 1 + guard case .identifier(let name)? = current else { + throw InvokerError.parse("expected a filter name after '|' in '\(source)'") + } + position += 1 + var arguments: [Any?] = [] + if consume(symbol: "(") { + if !consume(symbol: ")") { + repeat { arguments.append(try parseExpression()) } while consume(symbol: ",") + guard consume(symbol: ")") else { + throw InvokerError.parse("expected ')' after filter arguments in '\(source)'") + } + } + } + value = try filter(name, arguments, value) + } else { + break + } + } + return value + } + + mutating func parsePrimary() throws -> Any? { + guard let token = current else { + throw InvokerError.parse("unexpected end of expression '\(source)'") + } + + switch token { + case .number(let value, let isInteger): + position += 1 + return isInteger ? Int(value) : value + + case .string(let value): + position += 1 + return value + + case .identifier(let name): + position += 1 + switch name { + case "true", "True": return true + case "false", "False": return false + case "none", "None", "null": return nil + default: return scope[name] + } + + case .symbol("("): + position += 1 + let value = try parseExpression() + guard consume(symbol: ")") else { + throw InvokerError.parse("expected ')' in '\(source)'") + } + return value + + case .symbol("["): + position += 1 + var elements: [Any] = [] + if !consume(symbol: "]") { + repeat { elements.append(try parseExpression() ?? NSNull()) } while consume(symbol: ",") + guard consume(symbol: "]") else { + throw InvokerError.parse("expected ']' in '\(source)'") + } + } + return elements + + case .symbol(let symbol): + throw InvokerError.parse("unexpected '\(symbol)' in expression '\(source)'") + } + } + + // MARK: Operations + + func member(_ name: String, of value: Any?) -> Any? { + if let dictionary = value as? [String: Any] { return dictionary[name] } + // Jinja allows `list.0`; mirror that for numeric members. + if let array = value as? [Any], let index = Int(name) { + return index >= 0 && index < array.count ? array[index] : nil + } + return nil + } + + func subscriptValue(_ value: Any?, by index: Any?) -> Any? { + if let key = index as? String { return member(key, of: value) } + guard let array = value as? [Any], let position = index as? Int else { return nil } + // Negative indices count from the end, as in Python. + let resolved = position < 0 ? array.count + position : position + return resolved >= 0 && resolved < array.count ? array[resolved] : nil + } + + func applyTest(_ name: String, to value: Any?) throws -> Bool { + switch name { + case "defined": return value != nil && !(value is NSNull) + case "undefined": return value == nil || value is NSNull + case "none", "null": return value == nil || value is NSNull + case "string": return value is String + case "number": return value is Int || value is Double + case "boolean": return value is Bool + case "sequence", "iterable": return value is [Any] + case "mapping": return value is [String: Any] + case "even", "odd": + guard let int = value as? Int else { return false } + return name == "even" ? int % 2 == 0 : int % 2 != 0 + default: + throw InvokerError.parse("unsupported template test '\(name)'") + } + } + + func contains(_ container: Any?, _ needle: Any?) -> Bool { + if let string = container as? String { + return string.contains(JSONSupport.stringify(needle)) + } + if let dictionary = container as? [String: Any], let key = needle as? String { + return dictionary[key] != nil + } + if let array = container as? [Any] { + return array.contains { JSONSupport.equals($0, needle) } + } + return false + } + + func compare(_ symbol: String, _ left: Any?, _ right: Any?) throws -> Bool { + if symbol == "==" { return JSONSupport.equals(left, right) } + if symbol == "!=" { return !JSONSupport.equals(left, right) } + + // Ordering is defined for numbers and strings only; anything else is a + // template bug worth surfacing rather than silently reporting `false`. + if let a = numeric(left), let b = numeric(right) { + switch symbol { + case "<": return a < b + case "<=": return a <= b + case ">": return a > b + default: return a >= b + } + } + if let a = left as? String, let b = right as? String { + switch symbol { + case "<": return a < b + case "<=": return a <= b + case ">": return a > b + default: return a >= b + } + } + throw InvokerError.parse( + "cannot compare \(JSONSupport.stringify(left)) \(symbol) \(JSONSupport.stringify(right))" + + " in '\(source)'") + } + + func arithmetic(_ symbol: String, _ left: Any?, _ right: Any?) throws -> Any? { + guard let a = numeric(left), let b = numeric(right) else { + throw InvokerError.parse( + "cannot apply '\(symbol)' to \(JSONSupport.stringify(left))" + + " and \(JSONSupport.stringify(right)) in '\(source)'") + } + + let bothIntegers = isInteger(left) && isInteger(right) + switch symbol { + case "+": return bothIntegers ? Int(a + b) as Any : a + b + case "-": return bothIntegers ? Int(a - b) as Any : a - b + case "*": return bothIntegers ? Int(a * b) as Any : a * b + case "/": + guard b != 0 else { throw InvokerError.parse("division by zero in '\(source)'") } + return a / b + case "//": + guard b != 0 else { throw InvokerError.parse("division by zero in '\(source)'") } + return Int((a / b).rounded(.down)) + case "%": + guard b != 0 else { throw InvokerError.parse("division by zero in '\(source)'") } + return bothIntegers + ? Int(a.truncatingRemainder(dividingBy: b)) as Any + : a.truncatingRemainder(dividingBy: b) + default: + throw InvokerError.parse("unsupported operator '\(symbol)' in '\(source)'") + } + } + + func numeric(_ value: Any?) -> Double? { + switch value { + case let int as Int: return Double(int) + case let double as Double: return double + case let number as NSNumber: return number.doubleValue + default: return nil + } + } + + func isInteger(_ value: Any?) -> Bool { + if value is Int { return true } + if let number = value as? NSNumber { + return + !(number.doubleValue.truncatingRemainder( + dividingBy: 1) != 0) && !(value is Double) + } + return false + } + } +} diff --git a/runtime/swift/prompty/Sources/Prompty/Frontmatter.swift b/runtime/swift/prompty/Sources/Prompty/Frontmatter.swift new file mode 100644 index 000000000..1c50f0fc1 --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/Frontmatter.swift @@ -0,0 +1,88 @@ +import Foundation +/// Splits a `.prompty` file into YAML frontmatter and a markdown body. +/// +/// Frontmatter is delimited by a leading `---` or `+++` line and closed by a +/// line whose trimmed content is exactly `---` or `+++`. +import Yams + +public enum Frontmatter { + + /// Split raw file contents. + /// + /// Returns the parsed frontmatter mapping and the untrimmed body. A file with + /// no opening delimiter is treated as body-only with empty frontmatter. + /// + /// Scanning walks `Lines` rather than `firstIndex(of: "\n")`. Swift clusters a + /// CRLF pair into a single `Character`, so a character search for `"\n"` finds + /// nothing in a Windows-authored file: the opening delimiter looks unterminated + /// and the whole document is silently discarded. + /// + /// This is the one and only line-ending normalization in the load path — the + /// returned body always uses LF. Normalizing again upstream would be lossy: + /// `a\r\r\nb` collapses to `a\r\nb` on the first pass, and a second pass would + /// then read that residual CR + LF as a terminator and delete the lone CR that + /// `Lines` and the Rust reference both preserve. + public static func split(_ raw: String) throws -> (frontmatter: [String: Any], body: String) { + let lines = Lines.splitLineFeeds(raw) + let normalizedRaw = lines.joined(separator: "\n") + + // Leading blank lines are insignificant, and the delimiter may be indented. + // `isWhitespace` — not `.whitespaces` — so vertical tab, form feed, NEL, and + // the Unicode separators count as blank, matching the previous behavior. + var index = 0 + while index < lines.count, lines[index].allSatisfy({ $0.isWhitespace }) { + index += 1 + } + guard index < lines.count else { return ([:], normalizedRaw) } + + let opener = String(lines[index].drop(while: { $0.isWhitespace })) + guard opener.hasPrefix("---") || opener.hasPrefix("+++") else { + return ([:], normalizedRaw) + } + + // An opening delimiter with nothing after it: empty frontmatter, empty body. + guard index + 1 < lines.count else { return ([:], "") } + + var cursor = index + 1 + var yamlLines: [String] = [] + while cursor < lines.count { + let line = lines[cursor].trimmingCharacters(in: .whitespaces) + if line == "---" || line == "+++" { break } + yamlLines.append(lines[cursor]) + cursor += 1 + } + + guard cursor < lines.count else { + throw LoadError.invalidFrontmatter("Opening delimiter without closing match") + } + + // Rejoining with LF keeps the trailing terminator the character-index + // version produced, so YAML parsing sees byte-identical text. + let yamlText = yamlLines.isEmpty ? "" : yamlLines.joined(separator: "\n") + "\n" + let body = + cursor + 1 < lines.count + ? lines[(cursor + 1)...].joined(separator: "\n") + : "" + + return (try parseYAML(yamlText), body) + } + + /// Parse a YAML mapping into normalized `Any` values. + public static func parseYAML(_ yaml: String) throws -> [String: Any] { + let trimmed = yaml.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return [:] } + + let parsed: Any? + do { + parsed = try Yams.load(yaml: trimmed) + } catch { + throw LoadError.invalidFrontmatter(String(describing: error)) + } + + guard let normalized = JSONSupport.normalize(parsed) else { return [:] } + guard let mapping = normalized as? [String: Any] else { + throw LoadError.invalidFrontmatter("Frontmatter must be a YAML mapping") + } + return mapping + } +} diff --git a/runtime/swift/prompty/Sources/Prompty/Harness.swift b/runtime/swift/prompty/Sources/Prompty/Harness.swift new file mode 100644 index 000000000..f88593b8c --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/Harness.swift @@ -0,0 +1,322 @@ +import Foundation + +/// Durable harness adapters: event capture, journaling, replay verification, +/// checkpoint storage, permission resolution and host tool dispatch. +/// +/// These mirror `runtime/rust/prompty/src/harness.rs` so a session recorded by +/// one runtime replays identically in another. The protocols themselves are +/// owned by the generated model (`PromptyModel/pipeline/`); this file only +/// supplies reference implementations. + +// MARK: - Event capture + +/// Collects emitted events in memory. +/// +/// Reference implementations are handed to engines that may fan out across +/// tasks, so the buffers are lock-guarded and the type is `Sendable`. +import PromptyModel + +// MARK: - Journaling + +/// Appends replayable journal records as newline-delimited JSON. +/// +/// Every write is an append to a closed file handle rather than a retained one, +/// which is what makes the journal durable across a crash mid-turn. + +// MARK: - Replay verification + +/// Compares an expected journal against an actual one, position by position. + +// MARK: - Checkpoint storage + +/// Stores checkpoints in memory, keyed by session and checkpoint id. + +// MARK: - Permission resolution + +/// Approves every permission request. + +/// Denies every permission request. + +// MARK: - Host tool dispatch + +/// Dispatches host tool requests to registered local functions. +public final class CollectingEventSink: EventSink, @unchecked Sendable { + private let lock = NSLock() + private var turn: [TurnEvent] = [] + private var session: [SessionEvent] = [] + + public init() {} + + public var turnEvents: [TurnEvent] { + lock.lock() + defer { lock.unlock() } + return turn + } + + public var sessionEvents: [SessionEvent] { + lock.lock() + defer { lock.unlock() } + return session + } + + public func emitTurn(turnEvent: TurnEvent) throws -> Bool { + lock.lock() + defer { lock.unlock() } + turn.append(turnEvent) + return true + } + + public func emitSession(sessionEvent: SessionEvent) throws -> Bool { + lock.lock() + defer { lock.unlock() } + session.append(sessionEvent) + return true + } +} +public final class JsonlEventJournalWriter: EventJournalWriter, @unchecked Sendable { + private let lock = NSLock() + private let url: URL + private var closed = false + + public init(path: String) { + self.url = URL(fileURLWithPath: path) + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + } + + public var path: String { url.path } + + public func appendTurn(turnEvent: TurnEvent) throws -> Bool { + try append(["kind": "turn", "event": turnEvent.save()]) + } + + public func appendSession(sessionEvent: SessionEvent) throws -> Bool { + try append(["kind": "session", "event": sessionEvent.save()]) + } + + /// Writes the summary (when present) and seals the journal. A second close is + /// reported as `false` rather than silently succeeding, matching Rust. + public func close(summary: SessionSummary?) throws -> Bool { + lock.lock() + defer { lock.unlock() } + if closed { return false } + + if let summary { + guard try appendLocked(["kind": "summary", "summary": summary.save()]) else { return false } + } + closed = true + return true + } + + private func append(_ record: [String: Any]) throws -> Bool { + lock.lock() + defer { lock.unlock() } + if closed { return false } + return try appendLocked(record) + } + + private func appendLocked(_ record: [String: Any]) throws -> Bool { + guard let line = (JSONSupport.toJSON(record) + "\n").data(using: .utf8) else { return false } + + if !FileManager.default.fileExists(atPath: url.path) { + return FileManager.default.createFile(atPath: url.path, contents: line) + } + guard let handle = try? FileHandle(forWritingTo: url) else { return false } + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: line) + return true + } + + /// Read a journal back as records, dropping the trailing summary line. + /// + /// Useful for replay verification against a journal written by any runtime. + public static func readRecords(path: String) throws -> [[String: Any]] { + let contents = try String(contentsOfFile: path, encoding: .utf8) + return + Lines.split(contents) + .compactMap { JSONSupport.parse(json: String($0)) as? [String: Any] } + } +} +public struct ReferenceReplayVerifier: Sendable { + public init() {} + + public func verify(_ request: ReplayVerificationRequest) throws -> ReplayVerificationResult { + let expected = request.expected + let actual = request.actual + var mismatches: [ReplayMismatch] = [] + + for index in 0.. String? { + guard let record else { return nil } + return JSONSupport.toJSON(try record.save()) + } +} +public final class InMemoryCheckpointStore: CheckpointStore, @unchecked Sendable { + private let lock = NSLock() + private var checkpoints: [String: Checkpoint] = [:] + + public init() {} + + public func save(checkpoint: Checkpoint) async throws -> Checkpoint { + guard let sessionId = checkpoint.sessionId, let id = checkpoint.id else { + throw InvokerError.execution("Checkpoint requires both sessionId and id to be stored") + } + store(checkpoint, at: Self.key(sessionId, id)) + return checkpoint + } + + public func load(sessionId: String, checkpointId: String) async throws -> Checkpoint? { + read(Self.key(sessionId, checkpointId)) + } + + /// Ordered by id so replay and resume see a stable sequence. + public func listCheckpoints(sessionId: String) async throws -> [Checkpoint] { + all(for: sessionId) + } + + // Locking lives in synchronous helpers: holding a lock across a suspension + // point is unavailable from async contexts and is an error in Swift 6. + + private func store(_ checkpoint: Checkpoint, at key: String) { + lock.lock() + defer { lock.unlock() } + checkpoints[key] = checkpoint + } + + private func read(_ key: String) -> Checkpoint? { + lock.lock() + defer { lock.unlock() } + return checkpoints[key] + } + + private func all(for sessionId: String) -> [Checkpoint] { + lock.lock() + defer { lock.unlock() } + return + checkpoints.values + .filter { $0.sessionId == sessionId } + .sorted { ($0.id ?? "") < ($1.id ?? "") } + } + + private static func key(_ sessionId: String, _ checkpointId: String) -> String { + // Escape the separator so ids containing it cannot collide. + "\(sessionId.replacingOccurrences(of: "\u{1}", with: ""))\u{1}\(checkpointId)" + } +} +public struct AllowAllPermissionResolver: PermissionResolver, Sendable { + public init() {} + + public func request(request: PermissionRequest) async throws -> PermissionDecision { + PermissionDecision( + requestId: request.requestId, + toolCallId: request.toolCallId, + permission: request.permission, + approved: true, + reason: "allow_all" + ) + } +} +public struct DenyAllPermissionResolver: PermissionResolver, Sendable { + public init() {} + + public func request(request: PermissionRequest) async throws -> PermissionDecision { + PermissionDecision( + requestId: request.requestId, + toolCallId: request.toolCallId, + permission: request.permission, + approved: false, + reason: "deny_all" + ) + } +} +public struct FunctionHostToolExecutor: HostToolExecutor, Sendable { + public typealias Handler = @Sendable ([String: Any]) async throws -> Any + + private let handlers: [String: Handler] + + public init(handlers: [String: Handler]) { + self.handlers = handlers + } + + /// A missing handler or a throwing handler is reported as an unsuccessful + /// result rather than propagating, so one bad tool cannot abort the turn. + public func execute(request: HostToolRequest) async throws -> HostToolResult { + let started = Date() + + guard let handler = handlers[request.toolName] else { + return Self.failure( + request, + errorKind: "not_found", + message: "No host tool registered for '\(request.toolName)'", + started: started + ) + } + + do { + let value = try await handler(request.arguments ?? [:]) + var result = HostToolResult( + requestId: request.requestId, + toolCallId: request.toolCallId, + toolName: request.toolName, + success: true + ) + result.result = value + result.durationMs = Self.elapsedMs(started) + return result + } catch { + return Self.failure( + request, errorKind: "exception", message: "\(error)", started: started) + } + } + + private static func failure( + _ request: HostToolRequest, errorKind: String, message: String, started: Date + ) -> HostToolResult { + var result = HostToolResult( + requestId: request.requestId, + toolCallId: request.toolCallId, + toolName: request.toolName, + success: false + ) + result.result = ["message": message] + result.errorKind = errorKind + result.durationMs = elapsedMs(started) + return result + } + + private static func elapsedMs(_ started: Date) -> Double { + Date().timeIntervalSince(started) * 1000 + } +} diff --git a/runtime/swift/prompty/Sources/Prompty/JSONSchema.swift b/runtime/swift/prompty/Sources/Prompty/JSONSchema.swift new file mode 100644 index 000000000..640f2e49e --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/JSONSchema.swift @@ -0,0 +1,179 @@ +import Foundation + +/// Raised when a portable `Property` cannot be represented safely by the +/// provider's JSON Schema subset. +import PromptyModel + +/// Projection of the portable `Property` model onto JSON Schema. +/// +/// This mirrors the Rust reference implementation exactly so that every runtime +/// produces byte-identical request bodies for the shared wire vectors. +public struct SchemaError: Error, CustomStringConvertible, Equatable { + public let message: String + + public init(_ message: String) { + self.message = message + } + + public var description: String { message } + + static let invalidUnion = SchemaError( + "UnionProperty must contain exactly one non-empty `oneOf` or `anyOf` array" + ) + + static let unsupportedOneOf = SchemaError( + "OpenAI schemas do not support UnionProperty.oneOf; use the provider-supported anyOf composition" + ) +} +public enum JSONSchema { + + /// Map a Prompty property kind onto its JSON Schema type. + /// + /// Unrecognized kinds return `nil`; the caller then emits a bare `{}` so + /// provider-specific extension kinds degrade to "any value" rather than being + /// silently coerced to a string. + public static func jsonType(forKind kind: String) -> String? { + switch kind { + case "string": return "string" + case "integer": return "integer" + case "float", "number": return "number" + case "boolean": return "boolean" + case "array": return "array" + case "object": return "object" + default: return nil + } + } + + /// Convert one property into a recursive JSON Schema definition. + public static func schema(for property: Property, strict: Bool) throws -> [String: Any] { + var schema: [String: Any] = [:] + + if let type = jsonType(forKind: property.kindName) { + schema["type"] = type + } + if let description = property.propertyDescription, !description.isEmpty { + schema["description"] = description + } + if let values = property.enumValues { + schema["enum"] = values + } + + switch property.kindName { + case "array": + if let items = property.arrayItems, !(items.raw.isEmpty) { + schema["items"] = try JSONSchema.schema(for: items, strict: strict) + } + + case "object": + let children = property.objectProperties + if !children.isEmpty { + var nested: [String: Any] = [:] + var required: [String] = [] + for child in children where !child.name.isEmpty { + nested[child.name] = try JSONSchema.schema( + for: child, optional: !child.isRequired, strict: strict) + if child.isRequired { required.append(child.name) } + } + schema["properties"] = nested + if !required.isEmpty { schema["required"] = required } + schema["additionalProperties"] = false + } + + case "union": + let oneOf = property.unionOneOf + let anyOf = property.unionAnyOf + switch (!oneOf.isEmpty, !anyOf.isEmpty) { + case (true, false): + throw SchemaError.unsupportedOneOf + case (false, true): + schema["anyOf"] = try anyOf.map { try JSONSchema.schema(for: $0, strict: strict) } + default: + throw SchemaError.invalidUnion + } + + default: + break + } + + if property.isNullable { + addNullability(&schema) + } + return schema + } + + /// Convert a property that sits in an optional position. + /// + /// In strict mode every declared key must appear in `required`, so optional + /// members express their optionality through a nullable type instead. + public static func schema( + for property: Property, optional: Bool, strict: Bool + ) throws -> [String: Any] { + var result = try schema(for: property, strict: strict) + if strict, optional, !property.isNullable { + addNullability(&result) + } + return result + } + + /// Widen a schema so it also accepts JSON `null`. + public static func addNullability(_ schema: inout [String: Any]) { + if let type = schema["type"] as? String { + schema["type"] = [type, "null"] + } else if var branches = schema["anyOf"] as? [Any] { + branches.append(["type": "null"]) + schema["anyOf"] = branches + } else if !schema.isEmpty { + // The branch is built from a snapshot taken *before* insertion, and the + // `anyOf` key is added to the existing map rather than replacing it, so + // siblings such as `description` survive and the `enum` widening below + // still sees the original values. + let snapshot = schema + schema["anyOf"] = [snapshot, ["type": "null"]] + } + + if var values = schema["enum"] as? [Any] { + if !values.contains(where: { $0 is NSNull }) { + values.append(NSNull()) + schema["enum"] = values + } + } + } + + /// Build the `parameters` object schema for a tool. + public static func parameters(_ properties: [Property], strict: Bool) throws -> [String: Any] { + var fields: [String: Any] = [:] + var required: [String] = [] + + for property in properties { + fields[property.name] = try schema( + for: property, optional: !property.isRequired, strict: strict) + if strict || property.isRequired { + required.append(property.name) + } + } + + var schema: [String: Any] = ["type": "object", "properties": fields] + if !required.isEmpty { schema["required"] = required } + return schema + } + + /// Build the strict object schema used for structured output. + /// + /// Structured output is always strict: every declared output is listed in + /// `required` and additional keys are rejected. + public static func outputs(_ properties: [Property]) throws -> [String: Any] { + var fields: [String: Any] = [:] + var required: [String] = [] + + for property in properties { + fields[property.name] = try schema( + for: property, optional: !property.isRequired, strict: true) + required.append(property.name) + } + + var schema: [String: Any] = ["type": "object", "properties": fields] + if !required.isEmpty { schema["required"] = required } + schema["additionalProperties"] = false + return schema + } +} diff --git a/runtime/swift/prompty/Sources/Prompty/JSONSupport.swift b/runtime/swift/prompty/Sources/Prompty/JSONSupport.swift new file mode 100644 index 000000000..0ab2e2ece --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/JSONSupport.swift @@ -0,0 +1,161 @@ +/// Helpers for working with the loosely-typed `Any` values that the +/// Typra-generated model uses for free-form data (`metadata`, `inputs`, +/// raw provider payloads, and template values). +/// +/// The generated model is the canonical type layer, so the runtime deliberately +/// does not introduce a parallel `JSONValue` domain type. These functions supply +/// the few operations — truthiness, rendering, deep equality, path lookup — +/// that `Any` cannot provide on its own. +import Foundation + +public enum JSONSupport { + + /// Normalize a decoded value so that `NSNumber`/`NSNull` bridges behave + /// predictably regardless of whether the value came from JSON or YAML. + public static func normalize(_ value: Any?) -> Any? { + guard let value else { return nil } + if value is NSNull { return nil } + if let number = value as? NSNumber { + if isBool(number) { return number.boolValue } + let double = number.doubleValue + if double.rounded() == double && abs(double) < 9.007_199_254_740_992e15 { + return Int(double) + } + return double + } + if let dict = value as? [String: Any] { + var result: [String: Any] = [:] + for (key, element) in dict { + // A null-valued key is data, not absence: dropping it makes `raw_json` + // lossy and breaks equality against runtimes that preserve it. Arrays + // already keep their nulls, so keeping them here makes the two paths + // agree. + result[key] = normalize(element) ?? NSNull() + } + return result + } + if let array = value as? [Any] { + return array.map { normalize($0) ?? NSNull() } + } + return value + } + + private static func isBool(_ number: NSNumber) -> Bool { + // Foundation stores booleans as NSNumber; the encoded Objective-C type is + // the portable way to tell them apart from integers on every platform. + let encoding = String(cString: number.objCType) + return encoding == "c" || encoding == "B" + } + + /// Jinja/Mustache truthiness: `nil`, `false`, `0`, `""`, and empty + /// collections are falsy; everything else is truthy. + public static func isTruthy(_ value: Any?) -> Bool { + guard let value = normalize(value) else { return false } + switch value { + case let bool as Bool: return bool + case let int as Int: return int != 0 + case let double as Double: return double != 0 + case let string as String: return !string.isEmpty + case let array as [Any]: return !array.isEmpty + case let dict as [String: Any]: return !dict.isEmpty + case is NSNull: return false + default: return true + } + } + + /// Render a value the way a template engine would interpolate it. + /// + /// Missing values render as the empty string, integral doubles lose their + /// trailing `.0`, and containers fall back to compact JSON. + public static func stringify(_ value: Any?) -> String { + guard let value = normalize(value) else { return "" } + switch value { + case let string as String: return string + case let bool as Bool: return bool ? "true" : "false" + case let int as Int: return String(int) + case let double as Double: + if double.rounded() == double && abs(double) < 1e15 { + return String(Int(double)) + } + return String(double) + case is NSNull: return "" + default: + if let data = try? JSONSerialization.data( + withJSONObject: value, options: [.sortedKeys, .fragmentsAllowed]), + let text = String(data: data, encoding: .utf8) + { + return text + } + return String(describing: value) + } + } + + /// Look up a dotted path such as `user.name` or `items.0` in a value tree. + public static func lookup(_ path: String, in root: Any?) -> Any? { + var current = normalize(root) + for segment in path.split(separator: ".") { + guard let value = current else { return nil } + if let dict = value as? [String: Any] { + current = normalize(dict[String(segment)]) + } else if let array = value as? [Any], let index = Int(segment), + index >= 0, index < array.count + { + current = normalize(array[index]) + } else { + return nil + } + } + return current + } + + /// Structural equality across the `Any` values the model uses. + public static func equals(_ lhs: Any?, _ rhs: Any?) -> Bool { + let left = normalize(lhs) + let right = normalize(rhs) + switch (left, right) { + case (nil, nil): return true + case (nil, _), (_, nil): return false + default: break + } + if let l = left as? Bool, let r = right as? Bool { return l == r } + if let l = left as? String, let r = right as? String { return l == r } + if let l = numeric(left), let r = numeric(right) { return l == r } + if let l = left as? [Any], let r = right as? [Any] { + guard l.count == r.count else { return false } + return zip(l, r).allSatisfy { equals($0, $1) } + } + if let l = left as? [String: Any], let r = right as? [String: Any] { + guard l.count == r.count else { return false } + return l.allSatisfy { key, value in r[key] != nil && equals(value, r[key]) } + } + return false + } + + private static func numeric(_ value: Any?) -> Double? { + switch value { + case let int as Int: return Double(int) + case let double as Double: return double + default: return nil + } + } + + /// Parse a JSON string into normalized `Any`. + public static func parse(json: String) -> Any? { + guard let data = json.data(using: .utf8), + let value = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + else { return nil } + return normalize(value) + } + + /// Serialize a value to compact JSON, or `"null"` when it cannot be encoded. + public static func toJSON(_ value: Any?) -> String { + guard let value = normalize(value) else { return "null" } + if let data = try? JSONSerialization.data( + withJSONObject: value, options: [.sortedKeys, .fragmentsAllowed]), + let text = String(data: data, encoding: .utf8) + { + return text + } + return "null" + } +} diff --git a/runtime/swift/prompty/Sources/Prompty/Jinja2Renderer.swift b/runtime/swift/prompty/Sources/Prompty/Jinja2Renderer.swift new file mode 100644 index 000000000..47e08b65c --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/Jinja2Renderer.swift @@ -0,0 +1,326 @@ +import Foundation + +/// Renders the Jinja2 subset that Prompty templates use. +/// +/// Supported: `{{ expr }}` with dotted paths and filters, `{% if %}` / +/// `{% else %}` / `{% endif %}`, `{% for x in xs %}` / `{% endfor %}`, and +/// `{# comments #}`. +/// +/// Prompt templates are not HTML, so output is never escaped and whitespace is +/// preserved exactly. An undefined variable renders as an empty string. +import PromptyModel + +// MARK: - Template syntax tree + +/// A parsed template node. + +/// Recursive-descent parser for the supported Jinja2 subset. +public struct Jinja2Renderer: Renderer { + + public init() {} + + /// Render the agent's template. + /// + /// Inputs arrive already prepared: the pipeline has replaced rich-kind values + /// with nonce placeholders. Preparing again here would mint a *second* nonce + /// and leave the pipeline expanding a placeholder that no longer appears in + /// the output, so this stage is deliberately a plain render. + public func render(agent: Prompty, template: String, inputs: [String: Any]) async throws + -> String + { + try render(template: template, inputs: inputs) + } + + /// Render a raw template string. + public func render(template: String, inputs: [String: Any]) throws -> String { + var parser = TemplateParser(template) + let nodes = try parser.parseNodes(until: nil).nodes + var output = "" + try emit(nodes, scope: inputs, into: &output) + return output + } + + // MARK: - Evaluation + + private func emit(_ nodes: [Node], scope: [String: Any], into output: inout String) throws { + for node in nodes { + switch node { + case .text(let text): + output += text + + case .expression(let source): + output += JSONSupport.stringify(try evaluate(source, scope: scope)) + + case .conditional(let condition, let body, let alternate): + let value = try evaluate(condition, scope: scope) + try emit(JSONSupport.isTruthy(value) ? body : alternate, scope: scope, into: &output) + + case .loop(let variable, let source, let body): + let sequence = try evaluate(source, scope: scope) + for element in iterate(sequence) { + var inner = scope + inner[variable] = element + try emit(body, scope: inner, into: &output) + } + } + } + } + + private func iterate(_ value: Any?) -> [Any] { + switch value { + case let array as [Any]: return array + case let dict as [String: Any]: return dict.keys.sorted().map { $0 } + case let string as String: return string.map { String($0) } + default: return [] + } + } + + /// Evaluate a template expression. + /// + /// Delegates to ``ExpressionParser``, which understands comparisons, logical + /// operators, arithmetic, membership, indexing and filters. Anything outside + /// that grammar raises rather than silently resolving to `nil`. + private func evaluate(_ source: String, scope: [String: Any]) throws -> Any? { + try ExpressionParser.evaluate(source, scope: scope) { name, arguments, value in + try applyFilter(name: name, arguments: arguments, to: value) + } + } + + private func applyFilter(name: String, arguments: [Any?], to value: Any?) throws -> Any? { + switch name { + case "upper": + return JSONSupport.stringify(value).uppercased() + case "lower": + return JSONSupport.stringify(value).lowercased() + case "trim": + return JSONSupport.stringify(value).trimmingCharacters(in: .whitespacesAndNewlines) + case "length", "count": + switch value { + case let array as [Any]: return array.count + case let dict as [String: Any]: return dict.count + case let string as String: return string.count + case nil: return 0 + default: return JSONSupport.stringify(value).count + } + case "join": + let separator = arguments.first.flatMap { $0 as? String } ?? "" + let elements = iterate(value).map { JSONSupport.stringify($0) } + return elements.joined(separator: separator) + case "default", "d": + if value == nil || value is NSNull || (value as? String)?.isEmpty == true { + return arguments.first.flatMap { $0 } ?? "" + } + return value + case "first": + return iterate(value).first + case "last": + return iterate(value).last + case "reverse": + return iterate(value).reversed().map { $0 } + case "capitalize": + let string = JSONSupport.stringify(value) + guard let head = string.first else { return string } + return String(head).uppercased() + string.dropFirst().lowercased() + case "string": + return JSONSupport.stringify(value) + case "int": + if let int = value as? Int { return int } + if let double = value as? Double { return Int(double) } + return Int(JSONSupport.stringify(value)) ?? 0 + case "tojson", "to_json": + return JSONSupport.toJSON(value) + default: + throw InvokerError.parse("unsupported template filter '\(name)'") + } + } + + private func parseFilter(_ spec: String) -> (name: String, arguments: [String]) { + guard let open = spec.firstIndex(of: "("), spec.hasSuffix(")") else { + return (spec.trimmingCharacters(in: .whitespaces), []) + } + let name = String(spec[spec.startIndex.. [String] { + var parts: [String] = [] + var current = "" + var depth = 0 + var quote: Character? + + for character in text { + if let active = quote { + current.append(character) + if character == active { quote = nil } + continue + } + switch character { + case "\"", "'": + quote = character + current.append(character) + case "(", "[": + depth += 1 + current.append(character) + case ")", "]": + depth -= 1 + current.append(character) + case separator where depth == 0: + parts.append(current) + current = "" + default: + current.append(character) + } + } + parts.append(current) + return parts + } +} +enum Node { + case text(String) + case expression(String) + case conditional(condition: String, body: [Node], alternate: [Node]) + case loop(variable: String, source: String, body: [Node]) +} +struct TemplateParser { + private let characters: [Character] + private var index: Int = 0 + + init(_ template: String) { + self.characters = Array(template) + } + + /// Parse until one of `terminators` is reached. + /// + /// Returns the parsed nodes and the terminating tag, which is `nil` at end of + /// input. + mutating func parseNodes(until terminators: Set?) throws -> ( + nodes: [Node], terminator: String? + ) { + var nodes: [Node] = [] + var text = "" + + func flushText() { + if !text.isEmpty { + nodes.append(.text(text)) + text = "" + } + } + + while index < characters.count { + guard characters[index] == "{", index + 1 < characters.count else { + text.append(characters[index]) + index += 1 + continue + } + + switch characters[index + 1] { + case "{": + flushText() + nodes.append(.expression(try readDelimited(open: 2, close: "}}"))) + + case "#": + // Comments produce no output. + _ = try readDelimited(open: 2, close: "#}") + + case "%": + let tag = try readDelimited(open: 2, close: "%}") + let keyword = tag.split(separator: " ", maxSplits: 1).first.map(String.init) ?? tag + + if let terminators, terminators.contains(keyword) { + flushText() + return (nodes, tag) + } + + flushText() + switch keyword { + case "if": + nodes.append(try parseConditional(tag)) + case "for": + nodes.append(try parseLoop(tag)) + default: + throw InvokerError.parse("unsupported template tag '{% \(tag) %}'") + } + + default: + text.append(characters[index]) + index += 1 + } + } + + flushText() + if let terminators, !terminators.isEmpty { + throw InvokerError.parse( + "unclosed template block — expected {% \(terminators.sorted().joined(separator: " / ")) %}") + } + return (nodes, nil) + } + + private mutating func parseConditional(_ tag: String) throws -> Node { + let condition = String(tag.dropFirst("if".count)).trimmingCharacters(in: .whitespaces) + + let branch = try parseNodes(until: ["else", "elif", "endif"]) + guard let terminator = branch.terminator else { + throw InvokerError.parse("unclosed {% if %} block") + } + + let keyword = + terminator.split(separator: " ", maxSplits: 1).first.map(String.init) ?? terminator + switch keyword { + case "endif": + return .conditional(condition: condition, body: branch.nodes, alternate: []) + case "else": + let alternate = try parseNodes(until: ["endif"]) + guard alternate.terminator != nil else { + throw InvokerError.parse("unclosed {% if %} block") + } + return .conditional(condition: condition, body: branch.nodes, alternate: alternate.nodes) + default: + // `elif` is sugar for a nested conditional in the else branch. + let nested = try parseConditional("if" + terminator.dropFirst("elif".count)) + return .conditional(condition: condition, body: branch.nodes, alternate: [nested]) + } + } + + private mutating func parseLoop(_ tag: String) throws -> Node { + let expression = String(tag.dropFirst("for".count)).trimmingCharacters(in: .whitespaces) + let parts = expression.components(separatedBy: " in ") + guard parts.count == 2 else { + throw InvokerError.parse("malformed loop tag '{% \(tag) %}'") + } + + let body = try parseNodes(until: ["endfor"]) + guard body.terminator != nil else { + throw InvokerError.parse("unclosed {% for %} block") + } + + return .loop( + variable: parts[0].trimmingCharacters(in: .whitespaces), + source: parts[1].trimmingCharacters(in: .whitespaces), + body: body.nodes + ) + } + + /// Consume `open` characters, then everything up to `close`. + private mutating func readDelimited(open: Int, close: String) throws -> String { + index += open + let closing = Array(close) + let start = index + + while index < characters.count { + if characters[index] == closing[0], index + closing.count <= characters.count, + Array(characters[index.. String { + // Scalar-level check: a CRLF pair is one Character, so `contains("\r")` + // reports false for exactly the text this needs to catch. + guard text.unicodeScalars.contains("\r") else { return text } + + var out = String.UnicodeScalarView() + out.reserveCapacity(text.unicodeScalars.count) + + var scalars = text.unicodeScalars.makeIterator() + var pending = scalars.next() + while let scalar = pending { + guard scalar == "\r" else { + out.append(scalar) + pending = scalars.next() + continue + } + let next = scalars.next() + if next == "\n" { + out.append("\n") + pending = scalars.next() + } else { + out.append("\r") + pending = next + } + } + return String(out) + } + + /// Split on LF or CRLF, keeping empty lines. A lone CR stays as content. + /// + /// This is the template and transcript spelling: it mirrors the reference + /// runtime, which normalizes `\r\n` and then splits on `\n` alone. Blank lines + /// separate turns and paragraphs, so they must survive. + public static func splitLineFeeds(_ text: String) -> [String] { + scan(text, loneCarriageReturnTerminates: false, omittingEmpty: false) + } + + /// Split on CR, LF, or CRLF, and on nothing else. + /// + /// Empty lines are dropped. Used for line-delimited formats — JSONL journals + /// and server-sent events — where blank lines carry no record. Both formats + /// allow a bare CR to end a line, and neither can carry one as data: an + /// unescaped CR inside a JSON string is invalid JSON. + public static func split(_ text: String) -> [String] { + scan(text, loneCarriageReturnTerminates: true, omittingEmpty: true) + } + + private static func scan( + _ text: String, + loneCarriageReturnTerminates: Bool, + omittingEmpty: Bool + ) -> [String] { + var lines: [String] = [] + var current = String.UnicodeScalarView() + + func flush() { + let line = String(current) + current = String.UnicodeScalarView() + if !omittingEmpty || !line.isEmpty { lines.append(line) } + } + + var scalars = text.unicodeScalars.makeIterator() + var pending = scalars.next() + while let scalar = pending { + switch scalar { + case "\n": + flush() + pending = scalars.next() + case "\r": + // Look ahead one scalar: CRLF is a single terminator, never two. + let next = scalars.next() + if next == "\n" { + flush() + pending = scalars.next() + } else if loneCarriageReturnTerminates { + flush() + pending = next + } else { + current.append("\r") + pending = next + } + default: + current.append(scalar) + pending = scalars.next() + } + } + + // The trailing segment, which has no terminator after it. When empty lines + // are kept this reproduces `components(separatedBy:)`, which yields a final + // "" for text that ends in a terminator. + flush() + return lines + } +} diff --git a/runtime/swift/prompty/Sources/Prompty/Loader.swift b/runtime/swift/prompty/Sources/Prompty/Loader.swift new file mode 100644 index 000000000..2bb2bdb28 --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/Loader.swift @@ -0,0 +1,231 @@ +import Foundation +/// Options controlling how a `.prompty` file is loaded. +import PromptyModel + +/// Loads `.prompty` files into the Typra-generated `Prompty` model. + +/// Load a `.prompty` file. Shorthand for ``Loader/load(path:options:)``. + +/// Load a `.prompty` file asynchronously. +public struct LoadOptions { + /// Extra directories that `${file:...}` references may read from. The prompt + /// file's own directory is always permitted. + public var allowedFileRoots: [URL] + + public init(allowedFileRoots: [URL] = []) { + self.allowedFileRoots = allowedFileRoots + } + + public static let `default` = LoadOptions() +} +public enum Loader { + + /// Load a `.prompty` file from disk. + /// + /// Per the spec the runtime never auto-loads `.env` files — populating the + /// environment is the host application's responsibility. + public static func load( + path: String, + options: LoadOptions = .default + ) throws -> Prompty { + let url = URL(fileURLWithPath: path) + guard FileManager.default.fileExists(atPath: url.path) else { + throw LoadError.fileNotFound(path: path, detail: "No such file") + } + let raw: String + do { + raw = try String(contentsOf: url, encoding: .utf8) + } catch { + throw LoadError.fileNotFound(path: path, detail: String(describing: error)) + } + return try build(raw: raw, filePath: url, options: options) + } + + /// Load a `.prompty` file without blocking the calling thread. + public static func loadAsync( + path: String, + options: LoadOptions = .default + ) async throws -> Prompty { + try await Task.detached(priority: .userInitiated) { + try load(path: path, options: options) + }.value + } + + /// Load `.prompty` content that did not come from a file. + /// + /// `basePath` anchors `${file:...}` resolution. + public static func load( + contents: String, + basePath: String, + options: LoadOptions = .default + ) throws -> Prompty { + try build(raw: contents, filePath: URL(fileURLWithPath: basePath), options: options) + } + + // MARK: - Pipeline + + private static func build( + raw: String, + filePath: URL, + options: LoadOptions + ) throws -> Prompty { + // 1. Split frontmatter from the markdown body. `Frontmatter.split` owns line + // ending normalization and returns an LF body; normalizing here as well + // would run a second pass and swallow a lone CR in `\r\r\n`. + var (data, body) = try Frontmatter.split(raw) + + // 2. The body becomes `instructions`. Editors append trailing newlines, so + // trim the end only — leading and internal whitespace is significant. + let trimmedBody = trimTrailingNewlines(body) + if !trimmedBody.isEmpty { + data["instructions"] = trimmedBody + } + + // 3. A `.prompty` file always describes a prompt. + data["kind"] = Defaults.kind + + // 4. Resolve ${env:} / ${file:} before handing the tree to the model. + let agentDirectory = filePath.deletingLastPathComponent() + var tree: Any = data + try References.resolve( + &tree, agentDirectory: agentDirectory, allowedRoots: options.allowedFileRoots) + + guard var resolved = tree as? [String: Any] else { + throw LoadError.invalidFrontmatter("Frontmatter must be a YAML mapping") + } + + // 5. Normalize the shapes the schema accepts as shorthand. + normalizeModel(&resolved) + try normalizeProperties(&resolved, key: "inputs", path: "inputs") + try normalizeProperties(&resolved, key: "outputs", path: "outputs") + + // 6. Hand off to the generated model — the canonical type layer. + var agent: Prompty + do { + agent = try Prompty.load(resolved) + } catch { + throw LoadError.invalidModel(String(describing: error)) + } + + // 7. Record the source path so relative tool references can resolve later. + var metadata = agent.metadata ?? [:] + metadata[Defaults.sourcePathKey] = filePath.path + agent.metadata = metadata + + return agent + } + + private static func trimTrailingNewlines(_ text: String) -> String { + var result = Substring(text) + while let last = result.last, last == "\n" || last == "\r" { + result = result.dropLast() + } + return String(result) + } + + /// `model: gpt-4` is shorthand for `model: { id: gpt-4 }`. + private static func normalizeModel(_ data: inout [String: Any]) { + if let shorthand = data["model"] as? String { + data["model"] = ["id": shorthand] + } + } + + /// `inputs`/`outputs` accept three shapes. Normalize all of them to the + /// canonical list of named properties the model expects: + /// + /// - a list of properties (already canonical), + /// - a mapping of name to property object, + /// - a mapping of name to a scalar. + /// + /// The two scalar contracts are deliberately distinct: + /// + /// - **Named-collection shorthand** (`inputs: { city: Seattle }`): the key + /// supplies `name`, the scalar infers `kind`, and the scalar is stored as + /// `default`. `example` stays absent. + /// - **Direct property coercion** (a bare scalar as a *list* element) mirrors + /// the `@coerce` decorators on the TypeSpec `Property` model + /// (`#{ kind: "string", example: "{value}" }`) and stores `example`. + /// + /// An immediate array value is never a property in a name-keyed collection, + /// so it is rejected with its full dotted path. Arrays nested inside declared + /// property fields (`default`, `items`, `enumValues`) stay valid. + /// + /// The Swift emitter does not yet emit `@coerce` constructions for + /// polymorphic enums, so the loader performs the widening. + private static func normalizeProperties( + _ data: inout [String: Any], key: String, path: String + ) throws { + guard let value = data[key] else { return } + + if let list = value as? [Any] { + data[key] = try list.enumerated().map { index, element in + try normalizeProperty(element, path: "\(path)[\(index)]") + } + return + } + + guard let mapping = value as? [String: Any] else { return } + + data[key] = try mapping.keys.sorted().map { name -> Any in + let entryPath = "\(path).\(name)" + let raw = mapping[name] as Any + let normalized = JSONSupport.normalize(raw) + + // An immediate array can never be a named property entry. + if normalized is [Any] { + throw LoadError.invalidNamedCollectionEntry(path: entryPath, valueCategory: "array") + } + + // Named-collection scalar shorthand: key names it, scalar becomes `default`. + if !(normalized is [String: Any]) && !(raw is NSNull) { + return ["name": name, "kind": inferKind(raw), "default": raw] + } + + guard var property = try normalizeProperty(raw, path: entryPath) as? [String: Any] else { + return raw + } + property["name"] = name + return property + } + } + + /// Widen one property entry: dictionaries gain an inferred `kind`; bare + /// scalars become `{ kind: , example: }` per the direct + /// `@coerce` contract. + static func normalizeProperty(_ element: Any, path: String = "") throws -> Any { + if let dict = element as? [String: Any] { + var property = dict + if property["kind"] == nil { + property["kind"] = inferKind(property["default"] ?? property["example"]) + } + if let nested = property["properties"] { + var wrapper: [String: Any] = ["properties": nested] + try normalizeProperties(&wrapper, key: "properties", path: "\(path).properties") + property["properties"] = wrapper["properties"] + } + if let items = property["items"] { + property["items"] = try normalizeProperty(items, path: "\(path).items") + } + return property + } + if element is NSNull { return element } + return ["kind": inferKind(element), "example": element] + } + + private static func inferKind(_ value: Any?) -> String { + switch JSONSupport.normalize(value) { + case is Bool: return "boolean" + case is Int: return "integer" + case is Double: return "float" + case is [Any]: return "array" + case is [String: Any]: return "object" + default: return "string" + } + } +} +public func load(_ path: String, options: LoadOptions = .default) throws -> Prompty { + try Loader.load(path: path, options: options) +} +public func loadAsync(_ path: String, options: LoadOptions = .default) async throws -> Prompty { + try await Loader.loadAsync(path: path, options: options) +} diff --git a/runtime/swift/prompty/Sources/Prompty/ModelExtensions.swift b/runtime/swift/prompty/Sources/Prompty/ModelExtensions.swift new file mode 100644 index 000000000..0d88d0852 --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/ModelExtensions.swift @@ -0,0 +1,236 @@ +import Foundation + +// Convenience accessors over the Typra-generated model. +// +// These are extensions only — the generated types remain the single canonical +// domain layer. Nothing here redefines or shadows a generated type. + +import PromptyModel + +extension Property { + /// The property's raw frontmatter dictionary. + /// + /// Scalar kinds are preserved verbatim by the generated `Property.unknown` + /// case; the three declared subtypes round-trip through `save()`. + public var raw: [String: Any] { + switch self { + case .unknown(let dict): return dict + default: return (try? save()) ?? [:] + } + } + + /// The declared property name, or `""` when unnamed. + public var name: String { raw["name"] as? String ?? "" } + + /// The property's `kind` discriminator (`string`, `integer`, `thread`, ...). + public var kindName: String { raw["kind"] as? String ?? "" } + + /// A human-readable description, when declared. + public var propertyDescription: String? { raw["description"] as? String } + + /// Whether the property must be supplied by the caller. + public var isRequired: Bool { JSONSupport.isTruthy(raw["required"]) } + + /// The declared default, used to fill omitted inputs. + public var defaultValue: Any? { JSONSupport.normalize(raw["default"]) } + + /// The declared example. Examples are documentation only and are never used + /// to fill an omitted input. + public var exampleValue: Any? { JSONSupport.normalize(raw["example"]) } + + /// Allowed enumeration values, when declared. + public var enumValues: [Any]? { + guard let values = raw["enumValues"] as? [Any], !values.isEmpty else { return nil } + return values.map { JSONSupport.normalize($0) ?? NSNull() } + } + + /// Whether the property also accepts a JSON `null`. + public var isNullable: Bool { JSONSupport.isTruthy(raw["nullable"]) } + + /// The element schema of an `array` property, when declared. + public var arrayItems: Property? { + if case .arrayProperty(let array) = self { return array.items } + guard let items = raw["items"] else { return nil } + return try? Property.load(items) + } + + /// The child properties of an `object` property. + public var objectProperties: [Property] { + if case .objectProperty(let object) = self { return object.properties } + guard let nested = raw["properties"] as? [Any] else { return [] } + // `try?` deliberately drops a structurally invalid child: `build()` already + // rejects those upstream, so this only fires for hand-built `Property` + // values that never went through the loader. + return nested.compactMap { try? Property.load(Loader.normalizeProperty($0)) } + } + + /// The `oneOf` branches of a `union` property, when declared. + public var unionOneOf: [Property] { + if case .unionProperty(let union) = self { return union.oneOf ?? [] } + guard let branches = raw["oneOf"] as? [Any] else { return [] } + return branches.compactMap { try? Property.load($0) } + } + + /// The `anyOf` branches of a `union` property, when declared. + public var unionAnyOf: [Property] { + if case .unionProperty(let union) = self { return union.anyOf ?? [] } + guard let branches = raw["anyOf"] as? [Any] else { return [] } + return branches.compactMap { try? Property.load($0) } + } +} +extension Tool { + /// The tool's raw dictionary form. + public var raw: [String: Any] { (try? save()) ?? [:] } + + /// The declared tool name. + public var name: String { raw["name"] as? String ?? "" } + + /// The tool's `kind` discriminator (`function`, `mcp`, `openapi`, ...). + public var kindName: String { raw["kind"] as? String ?? "" } + + /// A human-readable description, when declared. + public var toolDescription: String? { + guard let value = raw["description"] as? String, !value.isEmpty else { return nil } + return value + } + + /// The tool's declared bindings, whichever concrete kind it is. + /// + /// `bindings` is inherited by every tool kind, but the generated `Tool` enum + /// reaches it only through its payload, so the switch lives here once. + public var bindings: [Binding] { + switch self { + case .functionTool(let tool): return tool.bindings ?? [] + case .mcpTool(let tool): return tool.bindings ?? [] + case .openApiTool(let tool): return tool.bindings ?? [] + case .promptyTool(let tool): return tool.bindings ?? [] + case .customTool(let tool): return tool.bindings ?? [] + } + } + + /// Parameter names bound to inputs. Bound parameters are stripped from the + /// schema sent to the provider — the runtime supplies them instead. + /// + /// An unnamed binding targets no parameter, so it strips nothing; that is the + /// same binding ``Pipeline/applyBindings(_:toolName:arguments:inputs:)`` + /// declines to inject, and the two must agree or a parameter would be removed + /// from the schema and never restored. + public var boundParameterNames: Set { + Set(bindings.map(\.name).filter { !$0.isEmpty }) + } + + /// The declared function parameters, for `function` tools. + public var functionParameters: [Property] { + if case .functionTool(let tool) = self { return tool.parameters } + return [] + } + + /// Whether the tool declares strict schema adherence. + public var isStrict: Bool { + if case .functionTool(let tool) = self { return tool.strict ?? false } + return false + } +} +extension Prompty { + /// The renderer key: `template.format.kind`, defaulting to `jinja2`. + /// + /// The generated model defaults `kind` to the wildcard sentinel `"*"`, which + /// means "unconstrained" in the schema and is never a registry key. A prompt + /// that sets only one side of `template` therefore leaves the other holding + /// `"*"`, which must resolve to the runtime default rather than fail lookup. + public var formatKind: String { + Self.resolveKind(template?.format.kind, default: Defaults.templateFormat) + } + + /// The parser key: `template.parser.kind`, defaulting to `prompty`. + public var parserKind: String { + Self.resolveKind(template?.parser.kind, default: Defaults.parser) + } + + private static func resolveKind(_ kind: String?, default fallback: String) -> String { + guard let kind, !kind.isEmpty, kind != "*" else { return fallback } + return kind + } + + /// The executor/processor key: `model.provider`, defaulting to `openai`. + public var providerKind: String { + guard let provider = model.provider, !provider.isEmpty else { return Defaults.provider } + return provider + } + + /// The API surface to call: `model.apiType`, defaulting to `chat`. + public var apiTypeName: String { + let name = model.apiType?.rawValue ?? "" + return name.isEmpty ? "chat" : name + } + + /// Whether the prompt asks the provider to stream its response. + public var isStreaming: Bool { + JSONSupport.isTruthy(model.options?.additionalProperties?["stream"]) + } + + /// Declared inputs, or an empty list. + public var inputProperties: [Property] { inputs ?? [] } + + /// Declared outputs, or an empty list. + public var outputProperties: [Property] { outputs ?? [] } + + /// Whether this prompt declares a structured output schema. + public var hasStructuredOutputs: Bool { !(outputs ?? []).isEmpty } + + /// The absolute path this prompt was loaded from, when known. + public var sourcePath: String? { metadata?[Defaults.sourcePathKey] as? String } +} +extension Message { + /// Build a single-text-part message. + public static func withText(_ role: Role, _ text: String) -> Message { + Message(role: role, parts: [.textPart(TextPart(kind: "text", value: text))]) + } + + /// Build a `tool` role message carrying a tool call result. + public static func toolResult(toolCallId: String, result: String) -> Message { + Message( + role: .tool, + parts: [.textPart(TextPart(kind: "text", value: result))], + metadata: ["tool_call_id": toolCallId] + ) + } + + /// All text parts concatenated. Non-text parts are ignored. + public var textContent: String { + parts.compactMap { part -> String? in + if case .textPart(let text) = part { return text.value } + return nil + }.joined() + } + + /// Wire-shaped content: a plain string when every part is text (joined by + /// newline), otherwise `nil` so the caller emits typed content blocks. + public var plainTextWireContent: String? { + guard !hasRichContent else { return nil } + return parts.compactMap { part -> String? in + if case .textPart(let text) = part { return text.value } + return nil + }.joined(separator: "\n") + } + + /// Whether the message carries any non-text content part. + public var hasRichContent: Bool { + parts.contains { part in + if case .textPart = part { return false } + return true + } + } +} +extension ContentPart { + /// Build a text content part. + public static func text(_ value: String) -> ContentPart { + .textPart(TextPart(kind: "text", value: value)) + } +} +extension Role { + /// Parse a role string, returning `nil` rather than throwing. + public static func parseOptional(_ value: String) -> Role? { + try? Role.parse(value.lowercased()) + } +} diff --git a/runtime/swift/prompty/Sources/Prompty/MustacheRenderer.swift b/runtime/swift/prompty/Sources/Prompty/MustacheRenderer.swift new file mode 100644 index 000000000..99fc526a9 --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/MustacheRenderer.swift @@ -0,0 +1,184 @@ +import Foundation + +/// Renders Mustache templates. +/// +/// Supported: `{{name}}` interpolation with dotted paths, `{{#section}}` +/// sections (truthy value, or iteration when the value is a list), `{{^section}}` +/// inverted sections, `{{.}}` implicit iteration context, and `{{! comment }}`. +/// +/// As with Jinja2, output is never HTML-escaped — `{{{triple}}}` and `{{&raw}}` +/// are accepted but behave identically to `{{double}}`. +import PromptyModel + +// MARK: - Template syntax tree + +public struct MustacheRenderer: Renderer { + + public init() {} + + /// Render the agent's template. + /// + /// Inputs arrive already prepared by the pipeline; see the note on + /// ``Jinja2Renderer/render(agent:template:inputs:)``. + public func render(agent: Prompty, template: String, inputs: [String: Any]) async throws + -> String + { + try render(template: template, inputs: inputs) + } + + /// Render a raw template string. + public func render(template: String, inputs: [String: Any]) throws -> String { + var scanner = Scanner(template) + let nodes = try scanner.parse(closing: nil).nodes + var output = "" + emit(nodes, stack: [inputs], into: &output) + return output + } + + // MARK: - Evaluation + + private func emit(_ nodes: [MustacheNode], stack: [Any], into output: inout String) { + for node in nodes { + switch node { + case .text(let text): + output += text + + case .variable(let name): + output += JSONSupport.stringify(resolve(name, stack: stack)) + + case .section(let name, let body): + let value = resolve(name, stack: stack) + if let array = value as? [Any] { + for element in array { + emit(body, stack: stack + [element], into: &output) + } + } else if JSONSupport.isTruthy(value) { + emit(body, stack: stack + [value as Any], into: &output) + } + + case .inverted(let name, let body): + let value = resolve(name, stack: stack) + let isEmpty = (value as? [Any])?.isEmpty ?? !JSONSupport.isTruthy(value) + if isEmpty { + emit(body, stack: stack, into: &output) + } + } + } + } + + /// Look up a name against the context stack, innermost frame first. + private func resolve(_ name: String, stack: [Any]) -> Any? { + // `.` refers to the current context itself. + if name == "." { return stack.last } + + for frame in stack.reversed() { + guard let dictionary = frame as? [String: Any] else { continue } + if let value = JSONSupport.lookup(name, in: dictionary) { return value } + } + return nil + } +} +enum MustacheNode { + case text(String) + case variable(String) + case section(name: String, body: [MustacheNode]) + case inverted(name: String, body: [MustacheNode]) +} +private struct Scanner { + private let characters: [Character] + private var index = 0 + + init(_ template: String) { + self.characters = Array(template) + } + + /// Parse until `{{/closing}}` is reached, or end of input when `closing` is nil. + mutating func parse(closing: String?) throws -> (nodes: [MustacheNode], closed: Bool) { + var nodes: [MustacheNode] = [] + var text = "" + + func flushText() { + if !text.isEmpty { + nodes.append(.text(text)) + text = "" + } + } + + while index < characters.count { + guard characters[index] == "{", index + 1 < characters.count, characters[index + 1] == "{" + else { + text.append(characters[index]) + index += 1 + continue + } + + // `{{{raw}}}` uses a three-character close. + let isTriple = index + 2 < characters.count && characters[index + 2] == "{" + let tag = try readTag(triple: isTriple) + + guard let sigil = tag.first else { + flushText() + nodes.append(.variable("")) + continue + } + + let name = String(tag.dropFirst()).trimmingCharacters(in: .whitespaces) + + switch sigil { + case "!": + // Comment — no output. + continue + + case "#", "^": + flushText() + let inner = try parse(closing: name) + guard inner.closed else { + throw InvokerError.parse("unclosed mustache section '{{#\(name)}}'") + } + nodes.append( + sigil == "#" + ? .section(name: name, body: inner.nodes) + : .inverted(name: name, body: inner.nodes)) + + case "/": + flushText() + guard let closing, closing == name else { + throw InvokerError.parse("unexpected mustache close tag '{{/\(name)}}'") + } + return (nodes, true) + + case "&": + flushText() + nodes.append(.variable(name)) + + default: + flushText() + nodes.append(.variable(tag.trimmingCharacters(in: .whitespaces))) + } + } + + flushText() + if closing != nil { + throw InvokerError.parse("unclosed mustache section '{{#\(closing!)}}'") + } + return (nodes, false) + } + + private mutating func readTag(triple: Bool) throws -> String { + let close = Array(triple ? "}}}" : "}}") + index += triple ? 3 : 2 + let start = index + + while index < characters.count { + if characters[index] == "}", index + close.count <= characters.count, + Array(characters[index.. [String: Any] { + var result = inputs + + for property in agent.inputProperties { + let name = property.name + guard !name.isEmpty, result[name] == nil else { continue } + + if let fallback = property.defaultValue { + result[name] = fallback + } else if property.isRequired { + throw InvokerError.validation("Missing required input: \"\(name)\"") + } + } + + return result + } + + // MARK: - Stages + + /// Render the prompt's instructions with the supplied inputs. + public static func render( + _ agent: Prompty, + inputs: [String: Any] = [:], + registry: Registry = .shared + ) async throws -> String { + try await renderWithNonces(agent, inputs: inputs, registry: registry).rendered + } + + /// Parse rendered text into messages. + /// + /// `context` carries the strict-mode nonce produced by the parser's + /// `preRender`. Passing `nil` parses without injection validation. + public static func parse( + _ agent: Prompty, + rendered: String, + context: [String: Any]? = nil, + registry: Registry = .shared + ) async throws -> [Message] { + registry.registerDefaults() + return try await registry.parser(for: agent.parserKind) + .parse(agent: agent, rendered: rendered, context: context) + } + + /// Render, parse, and expand rich-kind inputs into real messages. + /// + /// With strict mode on (the default) the parser stamps every role marker in + /// the template with a nonce before rendering, then requires that nonce when + /// parsing. Role markers that appear only after interpolation therefore fail + /// validation instead of silently becoming new turns. + public static func prepare( + _ agent: Prompty, + inputs: [String: Any] = [:], + registry: Registry = .shared + ) async throws -> [Message] { + registry.registerDefaults() + + let validated = try validateInputs(agent, inputs: inputs) + let parser = try registry.parser(for: agent.parserKind) + + var target = agent + var context: [String: Any]? + + if isStrict(agent) { + let result = try parser.preRender(template: agent.instructions ?? "") + if let preRendered = result as? PreRenderResult { + target.instructions = preRendered.text + context = preRendered.context + } else if result != nil { + // Silently discarding an unrecognized result would disable strict + // nonce validation — exactly the protection strict mode exists for. + throw InvokerError.parse( + "parser '\(agent.parserKind)' returned an unsupported pre-render result;" + + " strict mode requires a PreRenderResult") + } + } + + let render = try await renderWithNonces(target, inputs: validated, registry: registry) + let messages = try await parser.parse( + agent: agent, rendered: render.rendered, context: context) + + return expandThreads(messages, nonces: render.nonces, inputs: validated) + } + + /// Normalize a provider response. + public static func process( + _ agent: Prompty, + response: Any, + registry: Registry = .shared + ) async throws -> Any? { + let processed = try await registry.processor(for: agent.providerKind) + .process(agent: agent, response: response) + return Structured.wrapIfNeeded(agent, result: processed) + } + + /// Execute prepared messages and process the response. + /// + /// When the prompt asks for streaming, the stream is consumed here and its + /// text accumulated, so `run` always answers with a complete result. Routing + /// a streaming response through the non-streaming decoder would hand SSE + /// frames to a JSON parser. + public static func run( + _ agent: Prompty, + messages: [Message], + registry: Registry = .shared + ) async throws -> Any? { + if isStreaming(agent) { + do { + return try await accumulate( + try await stream(agent, messages: messages, registry: registry)) + } catch { + // A provider that cannot stream this request still has to answer, so + // fall through to the buffered path rather than failing the call. + } + } + + let executor = try registry.executor(for: agent.providerKind) + let response = try await executor.execute(agent: agent, messages: messages) + return Structured.unwrap(try await process(agent, response: response, registry: registry)) + } + + /// Whether the prompt asked for a streamed response. + static func isStreaming(_ agent: Prompty) -> Bool { + guard let value = agent.model.options?.additionalProperties?["stream"] else { return false } + return (JSONSupport.normalize(value) as? Bool) ?? false + } + + /// Drain a chunk stream into the text it represents. + static func accumulate(_ stream: ChunkStream) async throws -> String { + var text = "" + for try await chunk in stream { + if case .textChunk(let part) = chunk { + text += part.value + } + } + return text + } + + /// Run the full pipeline for a loaded prompt. + public static func invoke( + _ agent: Prompty, + inputs: [String: Any] = [:], + registry: Registry = .shared + ) async throws -> Any? { + let messages = try await prepare(agent, inputs: inputs, registry: registry) + return try await run(agent, messages: messages, registry: registry) + } + + /// Run the full pipeline for a prompt on disk. + public static func invoke( + path: String, + inputs: [String: Any] = [:], + options: LoadOptions = .default, + registry: Registry = .shared + ) async throws -> Any? { + let agent = try Loader.load(path: path, options: options) + return try await invoke(agent, inputs: inputs, registry: registry) + } + + /// Stream a prepared conversation, yielding decoded chunks as the provider + /// emits them. + public static func stream( + _ agent: Prompty, + messages: [Message], + registry: Registry = .shared + ) async throws -> ChunkStream { + let raw = try await registry.executor(for: agent.providerKind) + .executeStream(agent: agent, messages: messages) + let decoded = try await registry.processor(for: agent.providerKind).processStream(stream: raw) + + guard let stream = decoded as? ChunkStream else { + throw InvokerError.execution("processor did not return a decoded chunk stream") + } + return stream + } + + // MARK: - Tool turns + + /// One assistant turn that requested tools, plus the results being reported + /// back to the model. + /// + /// Appending these to the conversation and calling ``run(_:messages:registry:)`` + /// again continues the exchange. + public static func toolMessages( + _ agent: Prompty, + rawResponse: Any = [:] as [String: Any], + toolCalls: [ToolCall], + toolResults: [String], + textContent: String? = nil, + registry: Registry = .shared + ) throws -> [Message] { + try registry.executor(for: agent.providerKind) + .formatToolMessages( + rawResponse: rawResponse, + toolCalls: toolCalls, + toolResults: toolResults, + textContent: textContent + ) + } + + /// Read the tool calls out of a processed provider result. + /// + /// Processors project tool calls as a list of `{ id, name, arguments }` + /// dictionaries; anything else means the model answered with content. + public static func toolCalls(in result: Any?) -> [ToolCall] { + guard let entries = result as? [Any], !entries.isEmpty else { return [] } + + let calls = entries.compactMap { entry -> ToolCall? in + guard let dict = entry as? [String: Any], + let name = dict["name"] as? String, !name.isEmpty + else { return nil } + return ToolCall( + id: dict["id"] as? String ?? "", + name: name, + arguments: dict["arguments"] as? String ?? "" + ) + } + return calls.count == entries.count ? calls : [] + } + + // MARK: - Tool bindings + + /// Inject bound parameters into a tool call's arguments (spec §9.6). + /// + /// Bindings are the second half of a two-part contract. Wire conversion + /// strips every bound parameter from the schema sent to the model (§2.9.1.1), + /// so the model never sees — and never supplies — them. This restores those + /// parameters from the prompt's own inputs before the tool runs. Without it a + /// bound parameter is stripped and never replaced, and the tool is invoked + /// with an argument missing. + /// + /// Each binding names a parameter (`binding.name`) and the input to read it + /// from (`binding.input`). A binding whose input is absent is skipped rather + /// than injected as null, so a partially supplied set of inputs degrades to + /// the arguments the model provided instead of failing the call. + /// + /// Bindings take precedence over anything the model produced for the same + /// parameter (§2.9.1.3) — that is the point of binding it. + /// + /// Unknown tool names and tools without bindings pass through untouched, so + /// this is safe to call unconditionally on every tool call. + public static func applyBindings( + _ agent: Prompty, + toolName: String, + arguments: [String: Any], + inputs: [String: Any] + ) -> [String: Any] { + guard let tool = agent.tools?.first(where: { $0.name == toolName }) else { return arguments } + + let bindings = tool.bindings + guard !bindings.isEmpty else { return arguments } + + var merged = arguments + for binding in bindings where !binding.name.isEmpty { + guard let value = inputs[binding.input] else { continue } + merged[binding.name] = value + } + return merged + } + + /// ``applyBindings(_:toolName:arguments:inputs:)`` for a decoded tool call. + /// + /// Call this at dispatch time, on the arguments handed to the tool — not on + /// the recorded call. `ToolCall` is deliberately left untouched: a bound value + /// is hidden from the model on purpose (it may be a user id, a tenant, or a + /// credential), and rewriting the call would feed that value straight back + /// into the assistant tool-call history that ``toolMessages(_:results:)`` + /// sends on the next round. + /// + /// Bindings apply only when the payload is a JSON object, matching the + /// reference implementation: an array, a scalar, or malformed JSON is not an + /// argument object, so it is passed through rather than replaced by one that + /// contains only the bound values. + public static func boundArguments( + _ agent: Prompty, + call: ToolCall, + inputs: [String: Any] + ) -> [String: Any] { + let decoded = call.argumentValues + guard isArgumentObject(call.arguments) else { return decoded } + return applyBindings(agent, toolName: call.name, arguments: decoded, inputs: inputs) + } + + /// Whether a raw arguments payload is a JSON object bindings may be added to. + /// + /// Providers send an empty payload for a call with no arguments, which is an + /// empty object in every meaningful sense — and is exactly the case where a + /// tool's only parameters are bound ones. + private static func isArgumentObject(_ raw: String) -> Bool { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return true } + return JSONSupport.parse(json: trimmed) is [String: Any] + } + + // MARK: - Internals + + /// Strict mode defaults to on — role-marker injection is the risk being + /// mitigated, so it must be opted out of explicitly. + static func isStrict(_ agent: Prompty) -> Bool { + agent.template?.format.strict ?? true + } + + static func renderWithNonces( + _ agent: Prompty, + inputs: [String: Any], + registry: Registry + ) async throws -> (rendered: String, nonces: [String: String]) { + registry.registerDefaults() + + let validated = try validateInputs(agent, inputs: inputs) + let (prepared, nonces) = RenderCommon.prepareRenderInputs(agent, inputs: validated) + + // Nonce substitution happens here and only here. Renderers receive the + // already-substituted inputs so the nonces recorded above are exactly the + // ones that survive into the rendered text for `expandThreads` to find. + let rendered = try await registry.renderer(for: agent.formatKind) + .render(agent: agent, template: agent.instructions ?? "", inputs: prepared) + + return (rendered, nonces) + } + + /// Replace nonce placeholders with the messages they stand for. + /// + /// A message whose text contains a placeholder is split into the text before + /// it, the expanded thread messages, and the text after it. + static func expandThreads( + _ messages: [Message], + nonces: [String: String], + inputs: [String: Any] + ) -> [Message] { + guard !nonces.isEmpty else { return messages } + + var nonceToName: [String: String] = [:] + for (name, nonce) in nonces { nonceToName[nonce] = name } + + var result: [Message] = [] + + for message in messages { + var expanded = false + + for part in message.parts { + guard case .textPart(let textPart) = part else { continue } + + for (nonce, name) in nonceToName { + guard let range = textPart.value.range(of: nonce) else { continue } + + let before = String(textPart.value[.. [Message] { + let entries: [Any] + switch value { + case let array as [Any]: + entries = array + case let dict as [String: Any]: + entries = dict["messages"] as? [Any] ?? [] + default: + return [] + } + + return entries.compactMap { entry in + guard + let dict = entry as? [String: Any], + let roleName = dict["role"] as? String, + let role = Role.parseOptional(roleName) + else { return nil } + return .withText(role, threadText(dict["content"])) + } + } + + /// Flatten a thread entry's `content` into plain text. + static func threadText(_ content: Any?) -> String { + switch content { + case let text as String: + return text + case let parts as [Any]: + return + parts + .compactMap { part -> String? in + guard let part = part as? [String: Any] else { return part as? String } + // `kind` is absent on some transcript shapes; treat those as text. + let kind = part["kind"] as? String ?? "text" + guard kind == "text" else { return nil } + return part["value"] as? String ?? part["text"] as? String + } + .joined() + default: + return JSONSupport.stringify(content) + } + } +} +public func render(_ agent: Prompty, inputs: [String: Any] = [:]) async throws -> String { + try await Pipeline.render(agent, inputs: inputs) +} +public func prepare(_ agent: Prompty, inputs: [String: Any] = [:]) async throws -> [Message] { + try await Pipeline.prepare(agent, inputs: inputs) +} +public func run(_ agent: Prompty, messages: [Message]) async throws -> Any? { + try await Pipeline.run(agent, messages: messages) +} +public func invoke(_ agent: Prompty, inputs: [String: Any] = [:]) async throws -> Any? { + try await Pipeline.invoke(agent, inputs: inputs) +} +public func invoke(path: String, inputs: [String: Any] = [:]) async throws -> Any? { + try await Pipeline.invoke(path: path, inputs: inputs) +} +public func boundArguments( + _ agent: Prompty, call: ToolCall, inputs: [String: Any] = [:] +) -> [String: Any] { + Pipeline.boundArguments(agent, call: call, inputs: inputs) +} +public func registerDefaults() { + Registry.shared.registerDefaults() +} diff --git a/runtime/swift/prompty/Sources/Prompty/PromptyChatParser.swift b/runtime/swift/prompty/Sources/Prompty/PromptyChatParser.swift new file mode 100644 index 000000000..370b14fac --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/PromptyChatParser.swift @@ -0,0 +1,243 @@ +import Foundation + +/// Splits rendered text at role markers into messages. +/// +/// A role marker is a line that consists solely of `system:`, `user:`, or +/// `assistant:` — optionally preceded by whitespace and a `#`, and optionally +/// carrying an attribute block such as `system[nonce="abc"]:`. Case is ignored. +/// `developer:` is deliberately not a role marker. +/// +/// # Strict mode +/// +/// ``preRender(_:text:)`` stamps every marker with a random per-render nonce +/// before the template engine runs. ``parse(_:text:context:)`` then requires +/// that nonce on every marker it finds. A role marker that appears in rendered +/// output but not in the original template can only have come from interpolated +/// input, so it will lack the nonce and be rejected. That closes the prompt +/// injection path where a template variable smuggles in `system:`. +/// +/// Registered under the key `prompty`. +import PromptyModel + +public struct PromptyChatParser: Parser { + + public init() {} + + // A role marker occupying an entire line, with an optional attribute block. + private static let boundary = try! NSRegularExpression( + pattern: #"^\s*#?\s*(system|user|assistant)(\[(\w+\s*=\s*"?[^"]*"?\s*,?\s*)+\])?\s*:\s*$"#, + options: [.caseInsensitive] + ) + + // A single `key=value` pair inside an attribute block. + private static let attribute = try! NSRegularExpression( + pattern: #"(\w+)\s*=\s*"?([^",\]]*)"?"# + ) + + // MARK: - Parser + + /// Split a template or transcript into lines, tolerating Windows endings. + /// + /// The loader normalizes what it reads from disk, but a prompt built in + /// memory — `Prompty.load` from a dictionary, or a template assembled by a + /// host — never passes through it. Swift treats a CRLF pair as a single + /// grapheme, so splitting such text on "\n" yields no split at all and every + /// role marker is missed. `Lines` scans scalars, which never cluster. + static func splitLines(_ text: String) -> [String] { + Lines.splitLineFeeds(text) + } + + public func preRender(template: String) throws -> Any? { + let nonce = Self.generateNonce() + let sanitized = + Self.splitLines(template) + .map { line -> String in + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard let match = Self.matchBoundary(trimmed) else { return line } + return "\(match.role)[\(Self.nonceAttribute)=\"\(nonce)\"]:\n" + } + .joined(separator: "\n") + + return PreRenderResult(text: sanitized, context: [Self.nonceAttribute: nonce]) + } + + public func parse(agent: Prompty, rendered: String, context: [String: Any]?) async throws + -> [Message] + { + try Self.parseChat(rendered, expectedNonce: context?[Self.nonceAttribute] as? String) + } + + /// Reserved attribute key carrying the strict-mode nonce. + /// + /// Reserved in two senses: its value is never type-coerced (see + /// ``parseAttributes(_:)``), and it is stripped from message metadata rather + /// than surfaced to callers. + static let nonceAttribute = "nonce" + + // MARK: - Parsing + + /// Parse without nonce validation. + public static func parseChat(_ text: String) -> [Message] { + (try? parseChat(text, expectedNonce: nil)) ?? [] + } + + /// Parse, validating nonces when `expectedNonce` is supplied. + public static func parseChat(_ text: String, expectedNonce: String?) throws -> [Message] { + var messages: [Message] = [] + var currentRole: Role = .system + var contentLines: [String] = [] + var currentAttributes: [String: Any] = [:] + var hasRoleMarker = false + + for line in Self.splitLines(text) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + guard let match = matchBoundary(trimmed) else { + // Accumulate the original line — indentation inside a turn matters. + contentLines.append(line) + continue + } + + // A marker closes the turn before it. The `hasRoleMarker` guard keeps an + // empty turn (`system:` immediately followed by `user:`) while suppressing + // a phantom leading message when the text opens with a marker. + if !contentLines.isEmpty || hasRoleMarker { + messages.append( + try buildMessage( + role: currentRole, + content: joinAndTrim(contentLines), + attributes: currentAttributes, + expectedNonce: hasRoleMarker ? expectedNonce : nil + )) + contentLines.removeAll() + currentAttributes = [:] + } + + currentRole = Role.parseOptional(match.role) ?? .system + if let block = match.attributes { + currentAttributes = parseAttributes(block) + } + hasRoleMarker = true + } + + if !contentLines.isEmpty || hasRoleMarker { + messages.append( + try buildMessage( + role: currentRole, + content: joinAndTrim(contentLines), + attributes: currentAttributes, + expectedNonce: hasRoleMarker ? expectedNonce : nil + )) + } + + return messages + } + + private static func buildMessage( + role: Role, + content: String, + attributes: [String: Any], + expectedNonce: String? + ) throws -> Message { + if let expected = expectedNonce { + let actual = JSONSupport.stringify(attributes[Self.nonceAttribute]) + guard actual == expected else { + throw InvokerError.parse( + """ + Nonce mismatch — possible prompt injection detected (strict mode is \ + enabled). A template variable may be injecting role markers. + """ + ) + } + } + + // The nonce is a transport detail; everything else becomes metadata. + var metadata = attributes + metadata.removeValue(forKey: Self.nonceAttribute) + + return Message(role: role, parts: [.text(content)], metadata: metadata) + } + + // MARK: - Helpers + + /// Match a role marker, returning the role and raw attribute block. + static func matchBoundary(_ line: String) -> (role: String, attributes: String?)? { + let range = NSRange(line.startIndex.. 2, let attrRange = Range(match.range(at: 2), in: line) { + attributes = String(line[attrRange]) + } + return (role, attributes) + } + + /// Extract `key=value` pairs from an attribute block, coercing scalars. + /// + /// `nonce` is exempt from coercion. It is a reserved transport key holding an + /// opaque 16-character hex token, and coercing it corrupts the value: hex is a + /// superset of decimal, so coercion corrupts roughly 1 in 1,200 generated + /// nonces (`0663512342083e99` parses as `6.63512342083e+110`, + /// `9677e80871924237` overflows to `inf`, and `0419856025378190` loses its + /// leading zero). Any of those then fails to equal the nonce that produced it, + /// so strict mode rejects its own untampered output as prompt injection. + /// + /// The root cause is fixed here rather than by stringifying at the comparison + /// site, which is what the Rust runtime does (`parsers/prompty.rs:174-182`). + /// That approach still mismatches on values whose numeric round-trip is not + /// identity, such as a leading zero. Never coercing a value that is + /// specification-defined to be an opaque string removes the class entirely. + static func parseAttributes(_ raw: String) -> [String: Any] { + var result: [String: Any] = [:] + let range = NSRange(raw.startIndex.. String { + var joined = Substring(lines.joined(separator: "\n")) + while joined.first == "\n" { joined = joined.dropFirst() } + while joined.last == "\n" { joined = joined.dropLast() } + return String(joined) + } + + /// 8 random bytes rendered as 16 hex characters. + static func generateNonce() -> String { + var hex = "" + for _ in 0..<8 { + hex += String(format: "%02x", UInt8.random(in: 0...255)) + } + return hex + } +} diff --git a/runtime/swift/prompty/Sources/Prompty/Protocols.swift b/runtime/swift/prompty/Sources/Prompty/Protocols.swift new file mode 100644 index 000000000..f8998aad7 --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/Protocols.swift @@ -0,0 +1,96 @@ +import Foundation + +/// Pipeline stage conformances. +/// +/// The four stage protocols — `Renderer`, `Parser`, `Executor`, and `Processor` +/// — are emitted from the shared TypeSpec model, so this runtime adopts them +/// directly rather than declaring a competing set. This file only supplies the +/// defaults and the small amount of Swift-side typing the generated `Any` +/// signatures leave open. +/// +/// Conventions the generated signatures leave to each runtime, matched to the +/// Rust reference implementation: +/// +/// - `Executor.executeStream` returns a ``RawChunkStream`` of raw provider +/// chunks — exactly the SSE payloads, undecoded. +/// - `Processor.processStream` turns that raw stream into a ``ChunkStream`` of +/// generated `StreamChunk` models. +/// - `Parser.preRender` returns the rewritten template plus any context the +/// matching `parse` call needs, as `PreRenderResult`. + +/// The raw provider chunks an `Executor` streams. +import PromptyModel + +/// The decoded chunks a `Processor` streams. + +/// What a strict-mode parser hands back from `preRender`. + +// MARK: - Generated model ergonomics + +public typealias RawChunkStream = AsyncThrowingStream<[String: Any], Error> +public typealias ChunkStream = AsyncThrowingStream +public struct PreRenderResult { + /// The rewritten template to render. + public var text: String + /// Context the matching `parse` call needs to validate the render. + public var context: [String: Any] + + public init(text: String, context: [String: Any]) { + self.text = text + self.context = context + } +} +extension Parser { + /// Leave the template untouched. + /// + /// Only strict-mode parsers need to rewrite instructions before rendering. + public func preRender(template: String) throws -> Any? { nil } +} +extension Executor { + /// Report that this provider cannot stream. + public func executeStream(agent: Prompty, messages: [Message]) async throws -> Any { + throw InvokerError.execution("streaming is not supported by this executor") + } + + /// Report that this provider has no tool-turn representation. + public func formatToolMessages( + rawResponse: Any, toolCalls: [ToolCall], toolResults: [String], textContent: String? + ) throws -> [Message] { + throw InvokerError.execution("tool calling is not supported by this executor") + } +} +extension Processor { + /// Report that this provider cannot stream. + public func processStream(stream: Any) async throws -> Any { + throw InvokerError.execution("streaming is not supported by this processor") + } +} +extension StreamChunk { + /// Incremental assistant text. + public static func text(_ value: String) -> StreamChunk { + .textChunk(TextChunk(value: value)) + } + + /// A completed tool call. + public static func tool(_ call: ToolCall) -> StreamChunk { + .toolChunk(ToolChunk(toolCall: call)) + } + + /// The text carried by this chunk, when it carries any. + public var textValue: String? { + if case .textChunk(let chunk) = self { return chunk.value } + return nil + } + + /// The tool call carried by this chunk, when it carries one. + public var toolCallValue: ToolCall? { + if case .toolChunk(let chunk) = self { return chunk.toolCall } + return nil + } +} +extension ToolCall { + /// Decoded arguments, or an empty dictionary when the payload is unusable. + public var argumentValues: [String: Any] { + JSONSupport.parse(json: arguments) as? [String: Any] ?? [:] + } +} diff --git a/runtime/swift/prompty/Sources/Prompty/References.swift b/runtime/swift/prompty/Sources/Prompty/References.swift new file mode 100644 index 000000000..1274dac9a --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/References.swift @@ -0,0 +1,158 @@ +import Foundation + +/// Resolves `${protocol:value}` references in loaded frontmatter. +/// +/// Two protocols are supported: +/// - `${env:VAR}` and `${env:VAR:default}` read process environment variables. +/// - `${file:relative/path}` inlines a sibling file. `.json`, `.yaml`, and +/// `.yml` are parsed into structured values; anything else is inlined as text. +/// +/// Only whole-value references are resolved — a reference must be the entire +/// string. Unknown protocols are left untouched. +import Yams + +public enum References { + + /// Recursively resolve every reference in a value tree, in place. + public static func resolve( + _ value: inout Any, + agentDirectory: URL, + allowedRoots: [URL] + ) throws { + if var dict = value as? [String: Any] { + for key in dict.keys { + if let string = dict[key] as? String { + if let resolved = try resolveString( + string, key: key, agentDirectory: agentDirectory, allowedRoots: allowedRoots) + { + dict[key] = resolved + } + } else if var nested = dict[key] { + try resolve(&nested, agentDirectory: agentDirectory, allowedRoots: allowedRoots) + dict[key] = nested + } + } + value = dict + return + } + + if var array = value as? [Any] { + for index in array.indices { + if let string = array[index] as? String { + if let resolved = try resolveString( + string, key: "[\(index)]", agentDirectory: agentDirectory, allowedRoots: allowedRoots) + { + array[index] = resolved + } + } else { + var element = array[index] + try resolve(&element, agentDirectory: agentDirectory, allowedRoots: allowedRoots) + array[index] = element + } + } + value = array + } + } + + /// Resolve a single string. Returns `nil` when the string is not a reference. + public static func resolveString( + _ string: String, + key: String, + agentDirectory: URL, + allowedRoots: [URL] + ) throws -> Any? { + guard string.hasPrefix("${"), string.hasSuffix("}") else { return nil } + + let inner = String(string.dropFirst(2).dropLast()) + guard let colon = inner.firstIndex(of: ":") else { return nil } + + let proto = inner[inner.startIndex.. Any { + let separator = argument.firstIndex(of: ":") + let name = separator.map { String(argument[argument.startIndex..<$0]) } ?? argument + let fallback = separator.map { String(argument[argument.index(after: $0)...]) } + + // An explicitly empty variable is a value, not an absence — deliberately + // clearing a key must not silently fall back to a default. + if let value = ProcessInfo.processInfo.environment[name] { + return value + } + if let fallback { + return fallback + } + throw LoadError.envVarNotSet(varName: name, key: key) + } + + private static func resolveFile( + _ relativePath: String, + key: String, + agentDirectory: URL, + allowedRoots: [URL] + ) throws -> Any { + let requested = URL(fileURLWithPath: relativePath, relativeTo: agentDirectory) + let full = requested.standardizedFileURL.resolvingSymlinksInPath() + + guard FileManager.default.fileExists(atPath: full.path) else { + throw LoadError.fileReference(path: full.path, detail: "No such file") + } + + // File references are a host-controlled capability: by default they may not + // escape the prompt's own directory tree. + var roots = [agentDirectory.standardizedFileURL.resolvingSymlinksInPath()] + roots.append(contentsOf: allowedRoots.map { $0.standardizedFileURL.resolvingSymlinksInPath() }) + + let isPermitted = roots.contains { root in + full.path == root.path || full.path.hasPrefix(root.path + "/") + || full.path.hasPrefix(root.path + "\\") + } + guard isPermitted else { + throw LoadError.fileReference( + path: full.path, + detail: "File reference '\(relativePath)' for key '\(key)' resolves outside allowed roots" + ) + } + + let contents: String + do { + contents = try String(contentsOf: full, encoding: .utf8) + } catch { + throw LoadError.fileReference(path: full.path, detail: String(describing: error)) + } + + switch full.pathExtension.lowercased() { + case "json": + guard let parsed = JSONSupport.parse(json: contents) else { + throw LoadError.fileReference(path: full.path, detail: "Invalid JSON") + } + return parsed + case "yaml", "yml": + do { + guard let parsed = JSONSupport.normalize(try Yams.load(yaml: contents)) else { + throw LoadError.fileReference(path: full.path, detail: "Invalid YAML") + } + return parsed + } catch let error as LoadError { + throw error + } catch { + throw LoadError.fileReference( + path: full.path, detail: "Invalid YAML: \(String(describing: error))") + } + default: + return contents + } + } +} diff --git a/runtime/swift/prompty/Sources/Prompty/Registry.swift b/runtime/swift/prompty/Sources/Prompty/Registry.swift new file mode 100644 index 000000000..b4f12106c --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/Registry.swift @@ -0,0 +1,110 @@ +import Foundation +import PromptyModel + +/// Thread-safe lookup tables mapping spec keys to implementations. +/// +/// Renderers and parsers are keyed by `template.format.kind` and +/// `template.parser.kind`; executors and processors by `model.provider`. +public final class Registry: @unchecked Sendable { + + /// The registry used by the top-level pipeline functions. + public static let shared = Registry() + + private let lock = NSLock() + private var renderers: [String: Renderer] = [:] + private var parsers: [String: Parser] = [:] + private var executors: [String: Executor] = [:] + private var processors: [String: Processor] = [:] + + public init() {} + + // MARK: - Registration + + public func register(renderer: Renderer, for key: String) { + lock.lock() + defer { lock.unlock() } + renderers[key] = renderer + } + + public func register(parser: Parser, for key: String) { + lock.lock() + defer { lock.unlock() } + parsers[key] = parser + } + + public func register(executor: Executor, for key: String) { + lock.lock() + defer { lock.unlock() } + executors[key] = executor + } + + public func register(processor: Processor, for key: String) { + lock.lock() + defer { lock.unlock() } + processors[key] = processor + } + + // MARK: - Lookup + + public func renderer(for key: String) throws -> Renderer { + lock.lock() + defer { lock.unlock() } + guard let value = renderers[key] else { + throw InvokerError.notFound(group: "renderer", key: key) + } + return value + } + + public func parser(for key: String) throws -> Parser { + lock.lock() + defer { lock.unlock() } + guard let value = parsers[key] else { + throw InvokerError.notFound(group: "parser", key: key) + } + return value + } + + public func executor(for key: String) throws -> Executor { + lock.lock() + defer { lock.unlock() } + guard let value = executors[key] else { + throw InvokerError.notFound(group: "executor", key: key) + } + return value + } + + public func processor(for key: String) throws -> Processor { + lock.lock() + defer { lock.unlock() } + guard let value = processors[key] else { + throw InvokerError.notFound(group: "processor", key: key) + } + return value + } + + // MARK: - Defaults + + private var defaultsRegistered = false + + /// Register the renderers and parsers built into the core runtime. + /// + /// Providers register themselves — importing `PromptyOpenAI` and calling its + /// `registerOpenAI()` adds the OpenAI executor and processor. + /// + /// The lock is held across the whole installation. Publishing the flag first + /// and registering afterwards would let a concurrent caller observe + /// "registered" and then look up an empty table. + public func registerDefaults() { + lock.lock() + defer { lock.unlock() } + guard !defaultsRegistered else { return } + defaultsRegistered = true + + let jinja = Jinja2Renderer() + renderers["jinja2"] = jinja + // `nunjucks` is the JavaScript port of the same template language. + renderers["nunjucks"] = jinja + renderers["mustache"] = MustacheRenderer() + parsers["prompty"] = PromptyChatParser() + } +} diff --git a/runtime/swift/prompty/Sources/Prompty/RenderCommon.swift b/runtime/swift/prompty/Sources/Prompty/RenderCommon.swift new file mode 100644 index 000000000..e0b38c4ca --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/RenderCommon.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Shared renderer preparation. +import PromptyModel + +public enum RenderCommon { + + /// Replace rich-kind inputs with nonce placeholders. + /// + /// Thread, image, file, and audio inputs are structured values that cannot be + /// stringified into a template. Instead each is swapped for a unique marker + /// that the preparation stage expands back into real content parts after + /// parsing. Doing it this way keeps injected content out of the template + /// engine entirely. + public static func prepareRenderInputs( + _ agent: Prompty, + inputs: [String: Any] + ) -> (inputs: [String: Any], nonces: [String: String]) { + var prepared = inputs + var nonces: [String: String] = [:] + + for property in agent.inputProperties + where Defaults.richKinds.contains(property.kindName) { + let name = property.name + guard !name.isEmpty, prepared[name] != nil else { continue } + let nonce = makeNonce(for: name) + nonces[name] = nonce + prepared[name] = nonce + } + + return (prepared, nonces) + } + + /// Build a `__PROMPTY_THREAD_<8 hex>___` placeholder. + public static func makeNonce(for name: String) -> String { + var hex = "" + for _ in 0..<4 { + hex += String(format: "%02x", UInt8.random(in: 0...255)) + } + return "\(Defaults.threadNoncePrefix)\(hex)_\(name)__" + } +} diff --git a/runtime/swift/prompty/Sources/Prompty/Structured.swift b/runtime/swift/prompty/Sources/Prompty/Structured.swift new file mode 100644 index 000000000..80cf6c9ef --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/Structured.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Transport for structured (schema-shaped) results. +/// +/// When a prompt declares `outputs`, a processed object or array is wrapped so +/// that the exact provider JSON survives alongside the parsed value. Callers +/// that just want the data see it through ``unwrap(_:)``; callers that need a +/// lossless round-trip decode ``cast(_:as:)`` from the preserved raw JSON. +import PromptyModel + +public enum Structured { + + /// Wrap a processed result when the prompt declares outputs. + public static func wrapIfNeeded(_ agent: Prompty, result: Any?) -> Any? { + guard agent.hasStructuredOutputs else { return result } + guard result is [String: Any] || result is [Any] else { return result } + + return [ + Defaults.structuredMarker: true, + "data": result as Any, + "raw_json": JSONSupport.toJSON(result), + ] + } + + /// Whether a value is a structured transport envelope. + public static func isStructured(_ value: Any?) -> Bool { + guard let dict = value as? [String: Any] else { return false } + return JSONSupport.isTruthy(dict[Defaults.structuredMarker]) + } + + /// Unwrap the envelope, returning the payload. Non-envelopes pass through. + public static func unwrap(_ value: Any?) -> Any? { + guard isStructured(value), let dict = value as? [String: Any] else { return value } + return dict["data"] + } + + /// The preserved raw provider JSON, when present. + public static func rawJSON(_ value: Any?) -> String? { + guard isStructured(value), let dict = value as? [String: Any] else { return nil } + return dict["raw_json"] as? String + } + + /// Decode a structured result into a `Decodable` type. + /// + /// The preserved raw JSON is preferred so nothing is lost re-encoding the + /// intermediate representation. + public static func cast(_ value: Any?, as type: T.Type) throws -> T { + let json: String + if let raw = rawJSON(value) { + json = raw + } else if let text = unwrap(value) as? String { + // A bare string result is already JSON text. Re-encoding it would + // produce a JSON *string literal* and decoding into a structure would + // then fail, so it is used verbatim. + json = text + } else { + json = JSONSupport.toJSON(unwrap(value)) + } + + guard let data = json.data(using: .utf8) else { + throw InvokerError.processing("result is not valid UTF-8") + } + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw InvokerError.processing("failed to decode structured result: \(error)") + } + } + + /// Build a JSON Schema from a prompt's declared outputs. + /// + /// Providers use this to request schema-constrained responses. Structured + /// output is always strict. + public static func outputSchema(_ agent: Prompty) throws -> [String: Any]? { + let outputs = agent.outputProperties + guard !outputs.isEmpty else { return nil } + return try JSONSchema.outputs(outputs) + } +} diff --git a/runtime/swift/prompty/Sources/Prompty/TurnRunner.swift b/runtime/swift/prompty/Sources/Prompty/TurnRunner.swift new file mode 100644 index 000000000..c0de9d024 --- /dev/null +++ b/runtime/swift/prompty/Sources/Prompty/TurnRunner.swift @@ -0,0 +1,243 @@ +import Foundation + +/// A durable, replayable single-turn runner. +/// +/// This is the Swift counterpart of `ReferenceTurnRunner` in +/// `runtime/rust/prompty/src/harness.rs`. It drives one turn to completion — +/// model call, permission gate, host tool execution, checkpointing — while +/// emitting the exact event sequence pinned by +/// `spec/vectors/harness/replay_vectors.json`, so a journal produced here +/// replays identically against any other runtime. +/// +/// The clock and id factory are injected so a run is byte-for-byte +/// reproducible; durability adapters are injected so the same loop works +/// against in-memory, on-disk, or remote storage. +import PromptyModel + +public struct ReferenceTurnRunner { + public typealias ModelCallback = (TurnModelRequest) async throws -> TurnModelResponse + public typealias Clock = () -> String + public typealias IdFactory = (String) -> String + + private let eventSink: any EventSink + private let journal: any EventJournalWriter + private let checkpointStore: any CheckpointStore + private let permissionResolver: any PermissionResolver + private let hostToolExecutor: any HostToolExecutor + private let invokeModel: ModelCallback + private let now: Clock + private let nextId: IdFactory + + public init( + eventSink: any EventSink, + journal: any EventJournalWriter, + checkpointStore: any CheckpointStore, + permissionResolver: any PermissionResolver, + hostToolExecutor: any HostToolExecutor, + invokeModel: @escaping ModelCallback, + now: @escaping Clock = { ISO8601DateFormatter().string(from: Date()) }, + nextId: @escaping IdFactory = { "\($0)-\(UUID().uuidString)" } + ) { + self.eventSink = eventSink + self.journal = journal + self.checkpointStore = checkpointStore + self.permissionResolver = permissionResolver + self.hostToolExecutor = hostToolExecutor + self.invokeModel = invokeModel + self.now = now + self.nextId = nextId + } + + // MARK: - Turn loop + + public func run(_ request: RunTurnRequest) async throws -> RunTurnResult { + // A negative budget is clamped rather than rejected so a caller cannot + // accidentally invert the loop guard. + let maxIterations = Int(max(request.options?.maxIterations ?? 10, 0)) + let inputs = request.inputs ?? [:] + + var checkpoints: [Checkpoint] = [] + var toolResults: [HostToolResult] = [] + var pendingResults: [HostToolResult] = [] + var iteration = 0 + var output: Any? + var status: RunTurnStatus = .success + + try emitSession(.sessionStart, request: request) + try emitTurn(.turnStart, request: request, iteration: 0) + + // `maxIterations == 0` means "never call the model", so the guard is + // evaluated before the first invocation as well as between rounds. + while iteration < maxIterations { + var modelRequest = TurnModelRequest( + sessionId: request.sessionId, turnId: request.turnId, iteration: Int32(iteration)) + modelRequest.inputs = inputs + modelRequest.options = request.options + modelRequest.toolResults = pendingResults + + try emitTurn(.llmStart, request: request, iteration: iteration) + let response = try await invokeModel(modelRequest) + try emitTurn(.llmComplete, request: request, iteration: iteration) + + let checkpoint = try await recordCheckpoint( + request: request, iteration: iteration, state: response.checkpointState) + checkpoints.append(checkpoint) + + let requests = response.toolRequests ?? [] + guard !requests.isEmpty else { + output = response.output + iteration += 1 + break + } + + pendingResults = [] + for toolRequest in requests { + let result = try await runTool(toolRequest, request: request, iteration: iteration) + pendingResults.append(result) + toolResults.append(result) + } + try emitTurn(.messagesUpdated, request: request, iteration: iteration) + + iteration += 1 + + // The budget is consumed by the round that just ran, so exhaustion is + // reported against the iteration that would have run next. + if iteration >= maxIterations { + status = .error + output = ["message": "Maximum turn iterations reached"] + try emitTurn( + .error, request: request, iteration: iteration, payload: ["errorKind": "max_iterations"]) + break + } + } + + try emitTurn( + .turnEnd, request: request, iteration: iteration, payload: ["status": status.rawValue]) + try emitSession( + .sessionEnd, request: request, payload: ["status": status.rawValue]) + + var summary = SessionSummary(sessionId: request.sessionId) + summary.status = status == .success ? .success : .error + summary.turns = 1 + summary.checkpoints = Int32(checkpoints.count) + _ = try journal.close(summary: summary) + + var result = RunTurnResult( + sessionId: request.sessionId, turnId: request.turnId, status: status, + iterations: Int32(iteration)) + result.output = output + result.toolResults = toolResults + result.checkpoints = checkpoints + return result + } + + // MARK: - Tool round + + /// Gate a single tool call on permission, then execute it. + /// + /// A denial is turned into an unsuccessful result rather than an error so the + /// model sees — and can respond to — the refusal on the next iteration. + private func runTool( + _ toolRequest: HostToolRequest, request: RunTurnRequest, iteration: Int + ) async throws -> HostToolResult { + let requestId = toolRequest.requestId ?? nextId("exec") + let permissionId = "\(requestId)-permission" + + var permissionRequest = PermissionRequest(permission: "tool:\(toolRequest.toolName)") + permissionRequest.requestId = permissionId + permissionRequest.toolCallId = toolRequest.toolCallId + permissionRequest.target = toolRequest.toolName + + try emitTurn( + .permissionRequested, request: request, iteration: iteration, + payload: ["requestId": permissionId, "toolName": toolRequest.toolName]) + + let decision = try await permissionResolver.request(request: permissionRequest) + + try emitTurn( + .permissionCompleted, request: request, iteration: iteration, + payload: ["requestId": permissionId, "approved": decision.approved]) + + let result: HostToolResult + if decision.approved { + try emitTurn( + .toolExecutionStart, request: request, iteration: iteration, + payload: ["toolName": toolRequest.toolName]) + + result = try await hostToolExecutor.execute(request: toolRequest) + + try emitTurn( + .toolExecutionComplete, request: request, iteration: iteration, + payload: resultPayload(result)) + } else { + var denied = HostToolResult( + requestId: toolRequest.requestId, + toolCallId: toolRequest.toolCallId, + toolName: toolRequest.toolName, + success: false + ) + denied.errorKind = "permission_denied" + denied.result = ["message": decision.reason ?? "Permission denied"] + result = denied + } + + try emitTurn( + .toolResult, request: request, iteration: iteration, payload: resultPayload(result)) + return result + } + + private func resultPayload(_ result: HostToolResult) -> [String: Any] { + var payload: [String: Any] = [ + "toolName": result.toolName, + "success": result.success, + ] + if let errorKind = result.errorKind { payload["errorKind"] = errorKind } + return payload + } + + // MARK: - Durability + + private func recordCheckpoint( + request: RunTurnRequest, iteration: Int, state: [String: Any]? + ) async throws -> Checkpoint { + var checkpoint = Checkpoint(title: "Iteration \(iteration)") + checkpoint.id = nextId("checkpoint") + checkpoint.sessionId = request.sessionId + checkpoint.turnId = request.turnId + checkpoint.checkpointNumber = Int32(iteration) + checkpoint.state = state + checkpoint.createdAt = now() + + let saved = try await checkpointStore.save(checkpoint: checkpoint) + try emitSession( + .checkpointCreated, request: request, payload: ["checkpointId": saved.id ?? ""]) + return saved + } + + /// Every event goes to the sink first and the journal second, so an + /// observer never sees an event that was not durably recorded behind it. + private func emitTurn( + _ type: TurnEventType, request: RunTurnRequest, iteration: Int, + payload: [String: Any] = [:] + ) throws { + var event = TurnEvent(id: nextId("event"), type: type, timestamp: now()) + event.turnId = request.turnId + event.iteration = Int32(iteration) + event.payload = payload + + _ = try eventSink.emitTurn(turnEvent: event) + _ = try journal.appendTurn(turnEvent: event) + } + + private func emitSession( + _ type: SessionEventType, request: RunTurnRequest, payload: [String: Any] = [:] + ) throws { + var event = SessionEvent(id: nextId("event"), type: type, timestamp: now()) + event.sessionId = request.sessionId + event.turnId = request.turnId + event.payload = payload + + _ = try eventSink.emitSession(sessionEvent: event) + _ = try journal.appendSession(sessionEvent: event) + } +} diff --git a/runtime/swift/prompty/Sources/PromptyOpenAI/OpenAIConfig.swift b/runtime/swift/prompty/Sources/PromptyOpenAI/OpenAIConfig.swift new file mode 100644 index 000000000..3e68741a9 --- /dev/null +++ b/runtime/swift/prompty/Sources/PromptyOpenAI/OpenAIConfig.swift @@ -0,0 +1,80 @@ +import Foundation + +import Prompty + +import PromptyModel + +/// Shared configuration for talking to an OpenAI-compatible endpoint. +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif +struct OpenAIConfig { + var baseURL: URL + var apiKey: String + var model: String + var extraHeaders: [String: String] + + /// Derive request configuration from a prompt's model and connection. + /// + /// The API key is read from the connection when present, otherwise from + /// `OPENAI_API_KEY`. Populating the environment is the host's job — the + /// runtime never reads `.env` files itself. + static func resolve(_ agent: Prompty) throws -> OpenAIConfig { + let connection = agent.model.connection + var endpoint = "https://api.openai.com/v1" + var apiKey = "" + var headers: [String: String] = [:] + + if let connection { + let fields = connectionFields(connection) + if let value = fields["endpoint"] as? String, !value.isEmpty { endpoint = value } + if let value = fields["apiKey"] as? String, !value.isEmpty { apiKey = value } + if let value = fields["headers"] as? [String: Any] { + for (key, header) in value { headers[key] = JSONSupport.stringify(header) } + } + } + + if apiKey.isEmpty { + apiKey = ProcessInfo.processInfo.environment["OPENAI_API_KEY"] ?? "" + } + guard !apiKey.isEmpty else { + throw InvokerError.execution( + "no OpenAI API key — set model.connection.apiKey or the OPENAI_API_KEY environment variable" + ) + } + + let model = agent.model.id + guard !model.isEmpty else { + throw InvokerError.execution("no model id — set model.id in the prompt frontmatter") + } + + guard let url = URL(string: endpoint.hasSuffix("/") ? String(endpoint.dropLast()) : endpoint) + else { + throw InvokerError.execution("invalid endpoint '\(endpoint)'") + } + + return OpenAIConfig(baseURL: url, apiKey: apiKey, model: model, extraHeaders: headers) + } + + /// Flatten a connection into its raw fields. + static func connectionFields(_ connection: Connection) -> [String: Any] { + (try? connection.save()) ?? [:] + } + + /// Build a request against a path under the configured base URL. + func request(path: String, body: [String: Any]) throws -> URLRequest { + var request = URLRequest(url: baseURL.appendingPathComponent(path)) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + for (key, value) in extraHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + + guard let data = JSONSupport.toJSON(body).data(using: .utf8) else { + throw InvokerError.execution("failed to encode request body") + } + request.httpBody = data + return request + } +} diff --git a/runtime/swift/prompty/Sources/PromptyOpenAI/OpenAIExecutor.swift b/runtime/swift/prompty/Sources/PromptyOpenAI/OpenAIExecutor.swift new file mode 100644 index 000000000..3ccf5e47a --- /dev/null +++ b/runtime/swift/prompty/Sources/PromptyOpenAI/OpenAIExecutor.swift @@ -0,0 +1,159 @@ +import Foundation +import Prompty +import PromptyModel + +/// Calls the OpenAI API over HTTP. +/// +/// The executor is deliberately thin: request bodies come from ``OpenAIWire`` +/// so they stay identical to every other Prompty runtime, and responses are +/// handed to ``OpenAIProcessor`` unmodified. +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Minimal server-sent-events reader. +public struct OpenAIExecutor: Executor { + private let session: URLSession + + public init(session: URLSession = .shared) { + self.session = session + } + + // MARK: - Execute + + public func execute(agent: Prompty, messages: [Message]) async throws -> Any { + let config = try OpenAIConfig.resolve(agent) + let (path, body) = try OpenAIExecutor.request(agent, messages: messages) + return try await send(config.request(path: path, body: body)) + } + + /// Build the endpoint path and request body for a prompt's API type. + public static func request(_ agent: Prompty, messages: [Message]) throws -> ( + path: String, body: [String: Any] + ) { + switch agent.apiTypeName { + case "chat", "agent": + return ("chat/completions", try OpenAIWire.chatArgs(agent, messages: messages)) + case "responses": + return ("responses", try OpenAIWire.responsesArgs(agent, messages: messages)) + case "embedding": + return ("embeddings", OpenAIWire.embeddingArgs(agent, messages: messages)) + case "image": + return ("images/generations", OpenAIWire.imageArgs(agent, messages: messages)) + case let other: + throw InvokerError.execution("unsupported apiType '\(other)' for the openai provider") + } + } + + // MARK: - Stream + + /// Stream raw provider chunks. + /// + /// Returns a ``RawChunkStream``; decoding into `StreamChunk` values is the + /// processor's job, matching every other Prompty runtime. + public func executeStream(agent: Prompty, messages: [Message]) async throws -> Any { + let config = try OpenAIConfig.resolve(agent) + var (path, body) = try OpenAIExecutor.request(agent, messages: messages) + OpenAIWire.enableStreaming(&body, apiType: agent.apiTypeName) + let request = try config.request(path: path, body: body) + let session = self.session + + let stream: RawChunkStream = AsyncThrowingStream { continuation in + Task { + do { + for try await line in try await SSE.lines(for: request, session: session) { + guard let event = SSE.payload(of: line) else { continue } + if event == "[DONE]" { break } + guard let json = JSONSupport.parse(json: event) as? [String: Any] else { continue } + continuation.yield(json) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + return stream + } + + // MARK: - Tool turns + + public func formatToolMessages( + rawResponse: Any, toolCalls: [ToolCall], toolResults: [String], textContent: String? + ) throws -> [Message] { + var messages = OpenAIWire.toolMessages(toolCalls, results: toolResults) + if let text = textContent, !text.isEmpty, !messages.isEmpty { + messages[0].parts = [.textPart(TextPart(value: text))] + } + return messages + } + + // MARK: - Transport + + private func send(_ request: URLRequest) async throws -> [String: Any] { + let (data, response) = try await session.data(for: request) + + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + let body = String(data: data, encoding: .utf8) ?? "" + throw InvokerError.execution("OpenAI request failed (\(http.statusCode)): \(body)") + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw InvokerError.execution("OpenAI response was not a JSON object") + } + return json + } +} +enum SSE { + /// Stream response lines, using the platform's native byte stream when it is + /// available and falling back to a buffered read otherwise. + static func lines(for request: URLRequest, session: URLSession) async throws + -> AsyncThrowingStream + { + #if canImport(FoundationNetworking) + let (data, response) = try await session.data(for: request) + try validate(response, data: data) + let text = String(data: data, encoding: .utf8) ?? "" + return AsyncThrowingStream { continuation in + for line in Lines.split(text) { + continuation.yield(String(line)) + } + continuation.finish() + } + #else + let (bytes, response) = try await session.bytes(for: request) + try validate(response, data: Data()) + return AsyncThrowingStream { continuation in + Task { + do { + for try await line in bytes.lines { + continuation.yield(line) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + #endif + } + + /// The JSON payload of an SSE `data:` line, or `nil` for other lines. + /// + /// Trimming includes newlines so a line handed over with its CR still attached + /// — `CharacterSet.whitespaces` is space and tab only — does not defeat the + /// `[DONE]` sentinel comparison. + static func payload(of line: String) -> String? { + guard line.hasPrefix("data:") else { return nil } + let value = line.dropFirst("data:".count).trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private static func validate(_ response: URLResponse, data: Data) throws { + guard let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) else { + return + } + let body = String(data: data, encoding: .utf8) ?? "" + throw InvokerError.execution("OpenAI stream failed (\(http.statusCode)): \(body)") + } +} diff --git a/runtime/swift/prompty/Sources/PromptyOpenAI/OpenAIProcessor.swift b/runtime/swift/prompty/Sources/PromptyOpenAI/OpenAIProcessor.swift new file mode 100644 index 000000000..545a93c4d --- /dev/null +++ b/runtime/swift/prompty/Sources/PromptyOpenAI/OpenAIProcessor.swift @@ -0,0 +1,172 @@ +import Foundation + +/// Normalizes OpenAI responses into runtime values. +/// +/// The response shape alone determines the projection, so one processor serves +/// the Chat Completions, Responses, embedding, and image APIs. Behaviour is +/// pinned by `spec/vectors/process/process_vectors.json`. +import Prompty + +import PromptyModel + +public struct OpenAIProcessor: Processor { + public init() {} + + public func process(agent: Prompty, response: Any) async throws -> Any { + try OpenAIProcessor.processResponse(agent, response: response) + } + + /// Decode a ``RawChunkStream`` of provider events into generated + /// `StreamChunk` values. + public func processStream(stream: Any) async throws -> Any { + guard let raw = stream as? RawChunkStream else { + throw InvokerError.execution("expected a raw provider chunk stream") + } + + let decoded: ChunkStream = AsyncThrowingStream { continuation in + Task { + var accumulator = StreamAccumulator() + do { + for try await event in raw { + for chunk in accumulator.consume(event) { + continuation.yield(chunk) + } + } + for chunk in accumulator.finish() { + continuation.yield(chunk) + } + continuation.finish() + } catch { + continuation.yield(.errorChunk(ErrorChunk(message: "\(error)"))) + continuation.finish(throwing: error) + } + } + } + return decoded + } + + /// Project a raw provider response onto a runtime value. + /// + /// - Text responses become a `String`. + /// - Tool calls become `[[String: Any]]` of `{ id, name, arguments }`. + /// - Embeddings become a vector, or a list of vectors for batches. + /// - Images become the URL or base64 payload. + /// - When the prompt declares outputs, text is JSON-parsed; unparseable text + /// falls back to the raw string rather than failing the call. + public static func processResponse(_ agent: Prompty, response: Any) throws -> Any { + guard let body = response as? [String: Any] else { + return response + } + + if let choices = body["choices"] as? [Any] { + return try processChat(agent, choices: choices) + } + if body["output"] != nil || body["output_text"] != nil { + return try processResponses(agent, body: body) + } + if let data = body["data"] as? [Any] { + return try processData(data) + } + return body + } + + // MARK: - Chat Completions + + private static func processChat(_ agent: Prompty, choices: [Any]) throws -> Any { + guard let first = choices.first as? [String: Any], + let message = first["message"] as? [String: Any] + else { + return "" + } + + if let calls = message["tool_calls"] as? [Any], !calls.isEmpty { + return calls.compactMap { entry -> [String: Any]? in + guard let call = entry as? [String: Any] else { return nil } + let function = call["function"] as? [String: Any] ?? [:] + return [ + "id": call["id"] as? String ?? "", + "name": function["name"] as? String ?? "", + "arguments": function["arguments"] as? String ?? "", + ] + } + } + + if let refusal = message["refusal"] as? String, !refusal.isEmpty { + return refusal + } + + let content = message["content"] as? String ?? "" + return finalize(agent, text: content) + } + + // MARK: - Responses API + + private static func processResponses(_ agent: Prompty, body: [String: Any]) throws -> Any { + let output = body["output"] as? [Any] ?? [] + + let calls: [[String: Any]] = output.compactMap { entry in + guard let item = entry as? [String: Any], item["type"] as? String == "function_call" else { + return nil + } + return [ + "id": item["call_id"] as? String ?? item["id"] as? String ?? "", + "name": item["name"] as? String ?? "", + "arguments": item["arguments"] as? String ?? "", + ] + } + if !calls.isEmpty { return calls } + + if let text = body["output_text"] as? String, !text.isEmpty { + return finalize(agent, text: text) + } + + // Fall back to assembling `output_text` blocks when the convenience field + // is absent — some SDK shapes only carry the structured output items. + var assembled = "" + for entry in output { + guard let item = entry as? [String: Any], + let content = item["content"] as? [Any] + else { continue } + for block in content { + guard let part = block as? [String: Any], + part["type"] as? String == "output_text", + let text = part["text"] as? String + else { continue } + assembled += text + } + } + return finalize(agent, text: assembled) + } + + // MARK: - Embeddings and images + + private static func processData(_ data: [Any]) throws -> Any { + let entries = data.compactMap { $0 as? [String: Any] } + + if entries.allSatisfy({ $0["embedding"] != nil }) && !entries.isEmpty { + let vectors = entries.map { $0["embedding"] ?? [] } + return vectors.count == 1 ? vectors[0] : vectors + } + + let images: [Any] = entries.compactMap { entry in + if let url = entry["url"] as? String { return url } + if let b64 = entry["b64_json"] as? String { return b64 } + return nil + } + if images.isEmpty { return "" } + return images.count == 1 ? images[0] : images + } + + // MARK: - Structured output + + private static func finalize(_ agent: Prompty, text: String) -> Any { + guard agent.hasStructuredOutputs, !text.isEmpty else { return text } + guard let parsed = JSONSupport.parse(json: text), + parsed is [String: Any] || parsed is [Any] + else { + // A model that ignored the schema still returned something useful. + return text + } + return parsed + } +} diff --git a/runtime/swift/prompty/Sources/PromptyOpenAI/Registration.swift b/runtime/swift/prompty/Sources/PromptyOpenAI/Registration.swift new file mode 100644 index 000000000..6103629a8 --- /dev/null +++ b/runtime/swift/prompty/Sources/PromptyOpenAI/Registration.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Registers the OpenAI executor and processor. +/// +/// Call once during host startup: +/// +/// ```swift +/// import Prompty +/// import PromptyOpenAI +/// +/// registerOpenAI() +/// ``` +import Prompty + +import PromptyModel + +public func registerOpenAI(into registry: Registry = .shared) { + registry.register(executor: OpenAIExecutor(), for: "openai") + registry.register(processor: OpenAIProcessor(), for: "openai") +} diff --git a/runtime/swift/prompty/Sources/PromptyOpenAI/StreamAccumulator.swift b/runtime/swift/prompty/Sources/PromptyOpenAI/StreamAccumulator.swift new file mode 100644 index 000000000..df020aa69 --- /dev/null +++ b/runtime/swift/prompty/Sources/PromptyOpenAI/StreamAccumulator.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Assembles streamed OpenAI events into generated `StreamChunk` values. +/// +/// Text deltas are emitted as they arrive; tool calls arrive in fragments and +/// are only emitted once the stream ends, because a call's arguments are split +/// across many events. Usage totals are emitted last when the provider reports +/// them. +import Prompty + +import PromptyModel + +struct StreamAccumulator { + private var calls: [Int: ToolCall] = [:] + private var usage: [String: Any]? + + /// Fold one streamed event into the accumulator, emitting any chunks it + /// completes. + mutating func consume(_ event: [String: Any]) -> [StreamChunk] { + // Responses API events carry their delta at the top level. + if let type = event["type"] as? String { + return consumeResponsesEvent(type: type, event: event) + } + + if let reported = event["usage"] as? [String: Any] { usage = reported } + + guard let choices = event["choices"] as? [Any], + let choice = choices.first as? [String: Any], + let delta = choice["delta"] as? [String: Any] + else { return [] } + + var chunks: [StreamChunk] = [] + if let piece = delta["content"] as? String, !piece.isEmpty { + chunks.append(.text(piece)) + } + if let thinking = delta["reasoning_content"] as? String, !thinking.isEmpty { + chunks.append(.thinkingChunk(ThinkingChunk(value: thinking))) + } + + for entry in delta["tool_calls"] as? [Any] ?? [] { + guard let call = entry as? [String: Any] else { continue } + let index = (call["index"] as? Int) ?? 0 + var current = calls[index] ?? ToolCall() + if let id = call["id"] as? String, !id.isEmpty { current.id = id } + if let function = call["function"] as? [String: Any] { + if let name = function["name"] as? String, !name.isEmpty { current.name = name } + if let arguments = function["arguments"] as? String { current.arguments += arguments } + } + calls[index] = current + } + return chunks + } + + private mutating func consumeResponsesEvent(type: String, event: [String: Any]) + -> [StreamChunk] + { + switch type { + case "response.output_text.delta": + guard let piece = event["delta"] as? String, !piece.isEmpty else { return [] } + return [.text(piece)] + + case "response.reasoning_summary_text.delta": + guard let piece = event["delta"] as? String, !piece.isEmpty else { return [] } + return [.thinkingChunk(ThinkingChunk(value: piece))] + + case "response.output_item.done": + guard let item = event["item"] as? [String: Any], + item["type"] as? String == "function_call" + else { return [] } + calls[calls.count] = ToolCall( + id: item["call_id"] as? String ?? "", + name: item["name"] as? String ?? "", + arguments: item["arguments"] as? String ?? "" + ) + return [] + + case "response.completed": + if let response = event["response"] as? [String: Any], + let reported = response["usage"] as? [String: Any] + { + usage = reported + } + return [] + + case "error": + let message = (event["message"] as? String) ?? "stream error" + return [.errorChunk(ErrorChunk(message: message))] + + default: + return [] + } + } + + /// The chunks that can only be emitted once the stream is exhausted. + func finish() -> [StreamChunk] { + var chunks = calls.keys.sorted().map { StreamChunk.tool(calls[$0]!) } + + if let usage, let parsed = try? InvocationUsage.load(usage) { + chunks.append(.usageChunk(UsageChunk(usage: parsed))) + } + return chunks + } +} diff --git a/runtime/swift/prompty/Sources/PromptyOpenAI/Wire.swift b/runtime/swift/prompty/Sources/PromptyOpenAI/Wire.swift new file mode 100644 index 000000000..7ee9ae89f --- /dev/null +++ b/runtime/swift/prompty/Sources/PromptyOpenAI/Wire.swift @@ -0,0 +1,335 @@ +import Foundation + +/// Wire-format projection for the OpenAI Chat Completions and Responses APIs. +/// +/// Every function here mirrors the Rust reference implementation so all runtimes +/// produce identical request bodies for the shared `spec/vectors/wire` contract. +import Prompty + +import PromptyModel + +public enum OpenAIWire { + + // MARK: - Messages + + /// Project a message onto the Chat Completions wire shape. + public static func message(_ message: Message) -> [String: Any] { + var wire: [String: Any] = ["role": message.role.rawValue] + + for (key, value) in message.metadata where key != "role" && key != "content" { + wire[key] = value + } + + if let text = message.plainTextWireContent { + wire["content"] = text + } else { + wire["content"] = message.parts.map(part) + } + return wire + } + + /// Project a single content part onto its typed wire block. + public static func part(_ part: ContentPart) -> [String: Any] { + switch part { + case .textPart(let text): + return ["type": "text", "text": text.value] + + case .imagePart(let image): + var url: [String: Any] = ["url": image.source] + if let detail = image.detail { url["detail"] = detail } + return ["type": "image_url", "image_url": url] + + case .audioPart(let audio): + let format = audio.mediaType.map(audioFormat) ?? "wav" + return ["type": "input_audio", "input_audio": ["data": audio.source, "format": format]] + + case .filePart(let file): + return ["type": "file", "file": ["url": file.source]] + } + } + + /// Map an audio MIME type onto OpenAI's `format` token. + public static func audioFormat(_ mime: String) -> String { + switch mime { + case "audio/wav", "audio/x-wav": return "wav" + case "audio/mpeg", "audio/mp3": return "mp3" + case "audio/mp4": return "mp4" + case "audio/ogg": return "ogg" + case "audio/flac": return "flac" + case "audio/webm": return "webm" + case "audio/pcm": return "pcm" + default: + guard mime.hasPrefix("audio/") else { return "wav" } + return String(mime.dropFirst("audio/".count)) + } + } + + // MARK: - Request bodies + + /// Build the request body for a chat completions call. + public static func chatArgs(_ agent: Prompty, messages: [Message]) throws -> [String: Any] { + var args: [String: Any] = [ + "model": agent.model.id, + "messages": messages.map(message), + ] + + applyOptions(&args, agent.model.options, provider: "openai") + + let tools = try self.tools(agent) + if !tools.isEmpty { args["tools"] = tools } + + if let format = try responseFormat(agent) { + args["response_format"] = format + } + return args + } + + /// Build the request body for the Responses API. + /// + /// System and developer messages collapse into `instructions`; everything + /// else becomes an `input` item. + public static func responsesArgs(_ agent: Prompty, messages: [Message]) throws -> [String: Any] { + var systemParts: [String] = [] + var input: [Any] = [] + + for message in messages { + let role = message.role.rawValue + if role == "system" || role == "developer" { + systemParts.append(message.textContent) + } else { + input.append(responsesInput(message)) + } + } + + var args: [String: Any] = [ + "model": agent.model.id.isEmpty ? "gpt-4o" : agent.model.id, + "input": input, + ] + if !systemParts.isEmpty { + args["instructions"] = systemParts.joined(separator: "\n\n") + } + + applyOptions(&args, agent.model.options, provider: "responses") + + let tools = try responsesTools(agent) + if !tools.isEmpty { args["tools"] = tools } + + if let text = try responsesTextFormat(agent) { + args["text"] = text + } + return args + } + + /// Build the request body for an embedding call. + public static func embeddingArgs(_ agent: Prompty, messages: [Message]) -> [String: Any] { + var args: [String: Any] = [ + "model": agent.model.id.isEmpty ? "text-embedding-ada-002" : agent.model.id, + "input": textInput(messages), + ] + mergeAdditionalProperties(&args, agent.model.options, overwrite: true) + return args + } + + /// Build the request body for an image generation call. + public static func imageArgs(_ agent: Prompty, messages: [Message]) -> [String: Any] { + let prompt: String + switch textInput(messages) { + case let single as String: prompt = single + case let many as [String]: prompt = many.joined(separator: " ") + default: prompt = "" + } + + var args: [String: Any] = [ + "model": agent.model.id.isEmpty ? "dall-e-3" : agent.model.id, + "prompt": prompt, + ] + mergeAdditionalProperties(&args, agent.model.options, overwrite: true) + return args + } + + /// Turn on server-sent streaming for a request body. + /// + /// `chat` and `agent` calls also request usage on the terminal event so every + /// OpenAI-wire provider reports identical token counts. + public static func enableStreaming(_ body: inout [String: Any], apiType: String) { + body["stream"] = true + if apiType == "chat" || apiType == "agent" { + body["stream_options"] = ["include_usage": true] + } + } + + // MARK: - Options + + static func applyOptions( + _ args: inout [String: Any], _ options: ModelOptions?, provider: String + ) { + guard let options else { return } + + if let wire = try? options.toWire(provider) { + for (key, value) in wire where !(value is NSNull) { + args[key] = fixFloat(value) + } + } + mergeAdditionalProperties(&args, options, overwrite: false) + } + + static func mergeAdditionalProperties( + _ args: inout [String: Any], _ options: ModelOptions?, overwrite: Bool + ) { + guard let extras = options?.additionalProperties else { return } + for (key, value) in extras where overwrite || args[key] == nil { + args[key] = value + } + } + + /// `ModelOptions` stores fractional values as `Float`. Widening a `Float` to + /// `Double` for JSON serialization exposes binary artifacts (0.1 becomes + /// 0.10000000149011612), so round-trip through the shortest `Float` literal. + static func fixFloat(_ value: Any) -> Any { + guard let float = value as? Float else { return value } + return Double(String(float)) ?? Double(float) + } + + // MARK: - Tools + + /// Project every function tool onto the Chat Completions tool shape. + public static func tools(_ agent: Prompty) throws -> [[String: Any]] { + try (agent.tools ?? []) + .filter { $0.kindName == "function" } + .map(functionTool) + } + + static func functionTool(_ tool: Tool) throws -> [String: Any] { + var definition: [String: Any] = ["name": tool.name] + if let description = tool.toolDescription { definition["description"] = description } + + let bound = tool.boundParameterNames + let visible = tool.functionParameters.filter { !bound.contains($0.name) } + let strict = tool.isStrict + + var parameters = try JSONSchema.parameters(visible, strict: strict) + if strict { + parameters["additionalProperties"] = false + definition["strict"] = true + } + definition["parameters"] = parameters + + return ["type": "function", "function": definition] + } + + /// Project every function tool onto the Responses API's flat tool shape. + public static func responsesTools(_ agent: Prompty) throws -> [[String: Any]] { + try (agent.tools ?? []) + .filter { $0.kindName == "function" } + .map(responsesFunctionTool) + } + + static func responsesFunctionTool(_ tool: Tool) throws -> [String: Any] { + var wire: [String: Any] = ["type": "function", "name": tool.name] + if let description = tool.toolDescription { wire["description"] = description } + + let bound = tool.boundParameterNames + let visible = tool.functionParameters.filter { !bound.contains($0.name) } + let strict = tool.isStrict + + var parameters = try JSONSchema.parameters(visible, strict: strict) + if strict { + parameters["additionalProperties"] = false + wire["strict"] = true + } + wire["parameters"] = parameters + + return wire + } + + // MARK: - Structured output + + static func responseFormat(_ agent: Prompty) throws -> [String: Any]? { + guard let schema = try Structured.outputSchema(agent) else { return nil } + return [ + "type": "json_schema", + "json_schema": [ + "name": "structured_output", + "strict": true, + "schema": schema, + ], + ] + } + + static func responsesTextFormat(_ agent: Prompty) throws -> [String: Any]? { + guard let schema = try Structured.outputSchema(agent) else { return nil } + return [ + "format": [ + "type": "json_schema", + "name": "structured_output", + "schema": schema, + "strict": true, + ] + ] + } + + // MARK: - Agent loop + + /// Format tool results back into conversation messages. + /// + /// Produces one assistant message carrying `tool_calls` metadata, then one + /// `tool` message per result. + public static func toolMessages(_ calls: [ToolCall], results: [String]) -> [Message] { + var messages: [Message] = [] + + let wireCalls: [Any] = calls.map { call in + [ + "id": call.id, + "type": "function", + "function": ["name": call.name, "arguments": call.arguments], + ] + } + + var assistant = Message.withText(.assistant, "") + assistant.metadata = ["tool_calls": wireCalls] + messages.append(assistant) + + for (call, result) in zip(calls, results) { + var message = Message.toolResult(toolCallId: call.id, result: result) + var metadata = message.metadata + metadata["name"] = call.name + message.metadata = metadata + messages.append(message) + } + return messages + } + + /// Whether a durable message carries a provider-owned Responses function-call + /// item. Native `previous_response_id` continuation already owns that item. + public static func isResponsesFunctionCall(_ message: Message) -> Bool { + message.metadata["responses_function_call"] != nil + } + + static func responsesInput(_ message: Message) -> Any { + if let passthrough = message.metadata["responses_function_call"] { + return passthrough + } + + let content: Any = message.plainTextWireContent ?? message.parts.map(part) + + if let callId = message.metadata["tool_call_id"] { + let output: String + if let text = content as? String { + output = text + } else { + output = JSONSupport.toJSON(content) + } + return ["type": "function_call_output", "call_id": callId, "output": output] + } + + let role = message.role == .tool ? "user" : message.role.rawValue + return ["role": role, "content": content] + } + + // MARK: - Helpers + + static func textInput(_ messages: [Message]) -> Any { + let texts = messages.map(\.textContent).filter { !$0.isEmpty } + return texts.count == 1 ? texts[0] : texts + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/BindingExpectationPairingTests.swift b/runtime/swift/prompty/Tests/PromptyTests/BindingExpectationPairingTests.swift new file mode 100644 index 000000000..a094a9cd5 --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/BindingExpectationPairingTests.swift @@ -0,0 +1,200 @@ +import XCTest + +@testable import Prompty +@testable import PromptyModel + +/// How a vector's `bindings` expectation is paired with the loaded bindings. +/// +/// `validateBindings` addresses entries by name, which is sound only while +/// those names are unique. An object cannot carry the same key twice, so a +/// repeated name proves the source used the array fallback — the one +/// representation that carries order — and those entries are therefore +/// positional. Uniquely named entries stay order- and representation-agnostic, +/// because both forms are legal for them and object form's order is an +/// artefact of key iteration, not content. +/// +/// No spec vector declares a duplicate binding name today, so none of this is +/// reachable through `testLoadVectors`. These tests drive `validateBindings` +/// directly, to pin the rule before PR #447 lands vectors that rely on it — +/// otherwise the first duplicate-bearing vector would be compared by a rule +/// nothing had ever checked. +final class BindingExpectationPairingTests: XCTestCase { + + // MARK: - Fixture + + /// Load a function tool whose bindings are declared in array form, so the + /// declared order is the loaded order. + /// + /// The loaded shape is asserted here rather than assumed: if loading + /// collapsed the duplicates into a map or reordered the array, every + /// `XCTAssertThrowsError` below would still pass — for the wrong reason, on a + /// count mismatch that has nothing to do with pairing. + private func tool( + _ bindings: [(name: String, input: String)], + file: StaticString = #filePath, + line: UInt = #line + ) throws -> Tool { + let loaded = try Tool.load([ + "name": "lookup", + "kind": "function", + "bindings": bindings.map { ["name": $0.name, "input": $0.input] }, + ]) + XCTAssertEqual( + loaded.bindings.map(\.name), bindings.map(\.name), + "loader did not preserve the declared binding names in order", + file: file, line: line) + XCTAssertEqual( + loaded.bindings.map(\.input), bindings.map(\.input), + "loader did not preserve the declared binding inputs in order", + file: file, line: line) + return loaded + } + + private func validate(_ tool: Tool, _ bindings: Any) throws { + try LoadVectorTests.validateBindings(tool, expected: ["bindings": bindings], index: 0) + } + + // MARK: - Duplicate names are positional + + /// The case name addressing cannot see at all. + /// + /// Both expectations carry the same name *and* the same input, so a + /// `first(where:)` lookup satisfies both from the first entry and never + /// examines the second — reporting a pass while half the collection went + /// unverified. Positional comparison reaches it. + func testDuplicateNamesDoNotLeaveLaterEntriesUnverified() throws { + let loaded = try tool([("dup", "a"), ("dup", "wrong")]) + XCTAssertThrowsError( + try validate( + loaded, + [ + ["name": "dup", "input": "a"], + ["name": "dup", "input": "a"], + ]), + "the second duplicate entry was never compared") + } + + /// Duplicates come from the ordered representation, so exchanging the + /// payloads of two identically named entries is a real difference — not the + /// reordering of a keyed collection. A multiset comparison would call these + /// equal. + func testDuplicateNamesAreComparedPositionallyNotAsAMultiset() throws { + let loaded = try tool([("dup", "b"), ("dup", "a")]) + XCTAssertThrowsError( + try validate( + loaded, + [ + ["name": "dup", "input": "a"], + ["name": "dup", "input": "b"], + ]), + "exchanged payloads under a repeated name must not compare equal") + } + + /// Positive control for the two above: the same duplicated shape passes when + /// the positions do agree, so those failures are pairing-specific rather than + /// a blanket rejection of duplicate names. + func testDuplicateNamesPassWhenPositionsAgree() throws { + let loaded = try tool([("dup", "a"), ("dup", "b")]) + XCTAssertNoThrow( + try validate( + loaded, + [ + ["name": "dup", "input": "a"], + ["name": "dup", "input": "b"], + ])) + } + + // MARK: - Unique names stay agnostic + + /// Unique names must *not* be compared positionally. Object form is legal for + /// them and its order is an artefact, so asserting position here would reject + /// a conforming loader. + func testUniqueNamesStayOrderAgnosticInListForm() throws { + let loaded = try tool([("b", "2"), ("a", "1")]) + XCTAssertNoThrow( + try validate( + loaded, + [ + ["name": "a", "input": "1"], + ["name": "b", "input": "2"], + ])) + } + + /// Guards the test above from vacuity: order-agnostic must not mean + /// value-blind. + func testUniqueNamesStillCatchAWrongInput() throws { + let loaded = try tool([("b", "2"), ("a", "wrong")]) + XCTAssertThrowsError( + try validate( + loaded, + [ + ["name": "a", "input": "1"], + ["name": "b", "input": "2"], + ]), + "a wrong input must fail even though the names all match") + } + + /// Map expectations address by key, so they are order-agnostic for the same + /// reason — and cannot express duplicates at all. + func testMapFormStaysOrderAgnostic() throws { + let loaded = try tool([("b", "2"), ("a", "1")]) + XCTAssertNoThrow( + try validate(loaded, ["a": ["input": "1"], "b": ["input": "2"]])) + } + + /// The positional branch must compare names, not only inputs. Every entry + /// here carries the same input, so only the name comparison can see that two + /// entries changed places. + func testPositionalComparisonChecksNamesNotJustInputs() throws { + let loaded = try tool([("dup", "same"), ("dup", "same"), ("unique", "same")]) + XCTAssertThrowsError( + try validate( + loaded, + [ + ["name": "dup", "input": "same"], + ["name": "unique", "input": "same"], + ["name": "dup", "input": "same"], + ]), + "a name moved between positions must be caught even when inputs match") + } + + // MARK: - Empty names are positional too + + /// An empty name disqualifies object form exactly as a duplicate does, so it + /// equally proves the source was the ordered array fallback — even though + /// every name here is unique, and so would pass a uniqueness-only pre-scan + /// while the array had been reordered. + func testEmptyNameForcesPositionalComparison() throws { + let loaded = try tool([("named", "ok"), ("", "blank")]) + XCTAssertThrowsError( + try validate( + loaded, + [ + ["name": "", "input": "blank"], + ["name": "named", "input": "ok"], + ]), + "an empty name makes the collection ordered, so the reorder must be caught") + } + + /// Positive control for the above: an empty name is not rejected outright, + /// only held to its position. + func testEmptyNamePassesWhenPositionsAgree() throws { + let loaded = try tool([("", "blank"), ("named", "ok")]) + XCTAssertNoThrow( + try validate( + loaded, + [ + ["name": "", "input": "blank"], + ["name": "named", "input": "ok"], + ])) + } + + /// Object form cannot legally carry an empty key, so a map expectation that + /// does is malformed rather than something to address by name. + func testMapFormRejectsAnEmptyKey() throws { + let loaded = try tool([("", "blank")]) + XCTAssertThrowsError( + try validate(loaded, ["": ["input": "blank"]]), + "an empty key cannot be expressed in object form") + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/ConnectionRoundTripTests.swift b/runtime/swift/prompty/Tests/PromptyTests/ConnectionRoundTripTests.swift new file mode 100644 index 000000000..da3eb4ed9 --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/ConnectionRoundTripTests.swift @@ -0,0 +1,611 @@ +import Foundation + +import PromptyModel + +import XCTest + +@testable import Prompty + +/// Public load/save/reload gate for unknown `Connection` kinds. +/// +/// This is the Swift half of the canonical acceptance recorded in +/// `spec/spec.md` §2.5 and `spec/vectors/model/connection_roundtrip_vectors.json`. +/// Neither of those exists on this branch yet — the canonical vector landed on +/// a branch that carries no Swift runtime tree — so the three required cases +/// are declared inline here and will be repointed at the shared vector once +/// both live on the same commit. ``testCanonicalVectorStillAbsent`` is the +/// tripwire for that. +/// +/// The contract under test, in the exact words of the acceptance: +/// +/// 1. a known lowercase `reference` kind stays known and unchanged; +/// 2. an unknown `future-auth` kind preserves its discriminator **exactly** +/// and its payload **completely**, including nested object, list, null, +/// int, float and bool; +/// 3. a case-collision `Reference` is unknown — matching is case-sensitive — +/// and preserves its full payload. +/// +/// Unknown `Connection` is deliberately independent of unknown `Tool`. `Tool` +/// has a wildcard subtype in TypeSpec and resolves to `CustomTool`; +/// `Connection` has none, so its passthrough is a distinct representation +/// reached by a different path. ``testUnknownConnectionIsIndependentOfCustomTool`` +/// pins that they do not depend on one another. +/// +/// Everything here goes through the **public** API — `Prompty.load`, +/// `Connection.load`, `.save()` — so it tests the shipped surface rather than +/// generated internals, and no generated file is edited to make it pass. +/// +/// One assertion here is deliberately **stronger than the portable contract**: +/// the dynamic-type check that separates an integer from a whole-valued float. +/// The canonical promise is complete JSON-*value* preservation, not lexical +/// number spelling, and JavaScript and Go cannot preserve that distinction at +/// all. It is kept as Swift-runtime-specific coverage and must not be +/// generalized into the shared vector. See +/// ``testWholeValuedFloatCoverageIsRuntimeSpecific``. +final class ConnectionRoundTripTests: XCTestCase { + + // MARK: - Payloads + + /// An unknown kind carrying one of every JSON type the acceptance names. + /// + /// The nested values are the point: a passthrough that only kept top-level + /// scalars would satisfy a shallower test and still lose data. + private func futureAuthPayload() -> [String: Any] { + [ + "kind": "future-auth", + "endpoint": "https://future.example.com", + "nested": [ + "inner": "value", + "innerNull": NSNull(), + "innerList": [1, 2, 3], + "deeper": ["level": 3], + ], + "list": ["a", 1, 2.5, true, NSNull()], + "nullValue": NSNull(), + "intValue": 42, + "floatValue": 3.25, + // An integral-valued float: serializes as `3`, identical to the int + // above, so only a dynamic-type check can prove it stayed a Double. + // + // This is **Swift-runtime-specific coverage, not portable contract.** + // The canonical promise is complete JSON-*value* preservation, not + // lexical number spelling: JavaScript and Go's default JSON value models + // cannot preserve an integer-versus-whole-float distinction at all. The + // shared vector therefore covers integer and fractional numbers only, and + // this case must never be generalized into it. + "floatWhole": 3.0, + "boolTrue": true, + "boolFalse": false, + ] + } + + // MARK: - Helpers + + /// Canonical JSON text, so comparison is exact rather than + /// `Spec.equal`-lenient. + /// + /// ``Spec/equal(_:_:)`` normalizes `NSNull` to `nil` so a JSON null and an + /// absent value compare equal — correct for vector comparison, wrong here, + /// where preserving an explicit null *is* the requirement. Comparing + /// serialized text keeps nulls, catches dropped or added keys, preserves + /// list order, and stops a bool collapsing into a number. + /// + /// It does **not** separate an int from an integral float: Foundation's JSON + /// writer emits `3.0` as `3`. Numeric fidelity is checked separately by + /// ``assertSameShape(_:_:_:)``, which compares dynamic types. + private func canonical(_ value: Any) throws -> String { + let data = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) + return try XCTUnwrap(String(data: data, encoding: .utf8)) + } + + /// Assert two payloads hold the same dynamic Swift types at every position. + /// + /// This is what catches numeric coercion. `42` and `42.0` serialize to the + /// same JSON text, so ``canonical(_:)`` cannot tell them apart; `Int` and + /// `Double` are different types, so this can. It also catches a value that + /// has been pushed through `NSNumber` or restringified along the way. + private func assertSameShape( + _ actual: Any, _ expected: Any, _ path: String = "root", + file: StaticString = #filePath, line: UInt = #line + ) { + switch (expected, actual) { + case (let e as [String: Any], let a as [String: Any]): + XCTAssertEqual( + Set(a.keys), Set(e.keys), "key set differs at \(path)", file: file, line: line) + for (key, expectedValue) in e { + guard let actualValue = a[key] else { continue } + assertSameShape(actualValue, expectedValue, "\(path).\(key)", file: file, line: line) + } + case (let e as [Any], let a as [Any]): + XCTAssertEqual(a.count, e.count, "list length differs at \(path)", file: file, line: line) + for (index, expectedValue) in e.enumerated() where index < a.count { + assertSameShape(a[index], expectedValue, "\(path)[\(index)]", file: file, line: line) + } + default: + XCTAssertTrue( + type(of: actual) == type(of: expected), + "\(path): type changed from \(type(of: expected)) to \(type(of: actual))", + file: file, line: line) + } + } + + /// Load a connection, save it, and load the saved form again. + private func roundTrip(_ payload: [String: Any]) throws -> ( + first: Connection, saved: [String: Any], reloaded: Connection, resaved: [String: Any] + ) { + let first = try Connection.load(payload) + let saved = try first.save() + let reloaded = try Connection.load(saved) + let resaved = try reloaded.save() + return (first, saved, reloaded, resaved) + } + + private func unknownPayload(_ connection: Connection, _ label: String) throws -> [String: Any] { + guard case .unknown(let raw) = connection else { + return try XCTUnwrap(nil as [String: Any]?, "\(label) is not .unknown: \(connection)") + } + return raw + } + + // MARK: - Case 1 — known kind stays known + + /// A known lowercase `reference` must stay known and survive unchanged. + /// + /// This is the control. Without it, a passthrough that swallowed *every* + /// kind — including the known ones — would pass cases 2 and 3 while having + /// destroyed the typed model entirely. + func testKnownReferenceStaysKnownAndUnchanged() throws { + let payload: [String: Any] = [ + "kind": "reference", + "name": "my-connection", + "target": "azure-openai", + ] + + let result = try roundTrip(payload) + + guard case .referenceConnection(let first) = result.first else { + return XCTFail("known 'reference' did not load as ReferenceConnection: \(result.first)") + } + guard case .referenceConnection(let reloaded) = result.reloaded else { + return XCTFail("known 'reference' lost its type on reload: \(result.reloaded)") + } + + XCTAssertEqual(first.name, "my-connection") + XCTAssertEqual(first.target, "azure-openai") + XCTAssertEqual(reloaded.name, first.name) + XCTAssertEqual(reloaded.target, first.target) + + XCTAssertEqual(result.saved["kind"] as? String, "reference") + XCTAssertEqual( + try canonical(result.saved), try canonical(payload), + "known connection did not survive the round trip unchanged") + XCTAssertEqual( + try canonical(result.resaved), try canonical(payload), + "known connection is not stable across reload") + } + + // MARK: - Case 2 — unknown kind, exact discriminator, complete payload + + /// `future-auth` is unknown: the discriminator survives byte-exact and the + /// payload survives whole, including every nested JSON type. + func testUnknownFutureAuthPreservesDiscriminatorAndPayload() throws { + let payload = futureAuthPayload() + let result = try roundTrip(payload) + + let raw = try unknownPayload(result.first, "future-auth") + XCTAssertEqual( + raw["kind"] as? String, "future-auth", + "the discriminator must survive exactly, not be normalized or defaulted") + + // Whole-payload exactness: keys, nesting, list order, explicit nulls. + XCTAssertEqual( + try canonical(result.saved), try canonical(payload), + "unknown connection payload was not preserved completely") + XCTAssertEqual( + try canonical(result.resaved), try canonical(payload), + "unknown connection payload degraded on reload") + + // Type fidelity, which canonical text cannot see. Asserted on the + // *reloaded* payload, so a coercion introduced by save or by the second + // load is caught rather than only one applied on the way in. + let reloadedRaw = try unknownPayload(result.reloaded, "future-auth after reload") + assertSameShape(result.saved, payload, "saved") + assertSameShape(reloadedRaw, payload, "reloaded") + assertSameShape(result.resaved, payload, "resaved") + + // Spot-check the individual types the acceptance calls out, so a failure + // says which one was lost rather than just 'the JSON differs'. These read + // from the reloaded payload for the same reason. + XCTAssertEqual(reloadedRaw["intValue"] as? Int, 42) + XCTAssertEqual(reloadedRaw["floatValue"] as? Double, 3.25) + XCTAssertEqual(reloadedRaw["boolTrue"] as? Bool, true) + XCTAssertEqual(reloadedRaw["boolFalse"] as? Bool, false) + XCTAssertTrue(reloadedRaw["nullValue"] is NSNull, "explicit null was dropped") + XCTAssertEqual((reloadedRaw["nested"] as? [String: Any])?["inner"] as? String, "value") + XCTAssertTrue( + (reloadedRaw["nested"] as? [String: Any])?["innerNull"] is NSNull, "nested null was dropped") + XCTAssertEqual((reloadedRaw["list"] as? [Any])?.count, 5) + + // A bool must not have decayed into a number on the way through. + let boolText = try canonical(["v": reloadedRaw["boolTrue"] as Any]) + XCTAssertEqual(boolText, "{\"v\":true}", "bool was coerced to a number") + } + + /// Reload must be idempotent — a second and third pass change nothing. + /// + /// One round trip can hide a transform that is only applied on the way in; + /// running it again is what catches drift that compounds. + func testUnknownConnectionRoundTripIsIdempotent() throws { + let payload = futureAuthPayload() + var current = try Connection.load(payload) + let expected = try canonical(payload) + + for pass in 1...3 { + let saved = try current.save() + XCTAssertEqual(try canonical(saved), expected, "payload drifted on pass \(pass)") + current = try Connection.load(saved) + } + } + + // MARK: - Case 3 — case-collision is unknown + + /// `Reference` is **not** `reference`. Matching is exact and case-sensitive, + /// so a differently-cased known kind is unknown and keeps its full payload. + /// + /// This is the case a case-insensitive `switch` would silently get wrong: it + /// would bind `Reference` to `ReferenceConnection` and drop every field that + /// type does not declare. + func testCaseCollisionReferenceIsUnknownAndPreservesPayload() throws { + let payload: [String: Any] = [ + "kind": "Reference", + "name": "my-connection", + "target": "azure-openai", + "extraField": "must survive", + "nested": ["a": 1, "b": NSNull()], + ] + + let result = try roundTrip(payload) + + let raw = try unknownPayload(result.first, "case-collision 'Reference'") + XCTAssertEqual( + raw["kind"] as? String, "Reference", + "the discriminator was case-folded; matching must be case-sensitive") + + XCTAssertEqual( + try canonical(result.saved), try canonical(payload), + "case-collision payload was not preserved completely") + XCTAssertEqual( + try canonical(result.resaved), try canonical(payload), + "case-collision payload degraded on reload") + + // The fields ReferenceConnection does not declare are exactly what a + // wrong case-insensitive match would have discarded. + XCTAssertEqual(raw["extraField"] as? String, "must survive") + XCTAssertTrue((raw["nested"] as? [String: Any])?["b"] is NSNull) + } + + /// Every other casing is unknown too, so the rule is 'exact match' rather + /// than 'these two spellings are special'. + func testOtherCasingsAreAlsoUnknown() throws { + for kind in ["REFERENCE", "Key", "REMOTE", "Anonymous", "OAuth", "Foundry"] { + let connection = try Connection.load(["kind": kind, "marker": kind]) + guard case .unknown(let raw) = connection else { + XCTFail("'\(kind)' matched a known subtype; matching is not case-sensitive") + continue + } + XCTAssertEqual(raw["kind"] as? String, kind) + XCTAssertEqual(raw["marker"] as? String, kind) + } + } + + /// The known kinds still resolve, so case sensitivity did not simply break + /// matching for everything. + func testCanonicalLowercaseKindsAllResolve() throws { + // Matched by pattern rather than by rendered description: an enum's + // `String(describing:)` is a debug affordance, not a contract. + let cases: [(String, (Connection) -> Bool)] = [ + ("reference", { if case .referenceConnection = $0 { return true } else { return false } }), + ("remote", { if case .remoteConnection = $0 { return true } else { return false } }), + ("key", { if case .apiKeyConnection = $0 { return true } else { return false } }), + ("anonymous", { if case .anonymousConnection = $0 { return true } else { return false } }), + ("oauth", { if case .oAuthConnection = $0 { return true } else { return false } }), + ("foundry", { if case .foundryConnection = $0 { return true } else { return false } }), + ] + + for (kind, matches) in cases { + let connection = try Connection.load(["kind": kind]) + if case .unknown = connection { + XCTFail("known kind '\(kind)' fell through to .unknown") + continue + } + XCTAssertTrue(matches(connection), "'\(kind)' resolved to the wrong subtype") + } + } + + // MARK: - Independence from Tool → CustomTool + + /// Unknown `Connection` and unknown `Tool` are separate mechanisms. + /// + /// `Tool` has a wildcard subtype in TypeSpec, so an unknown kind becomes a + /// typed `CustomTool`; `Connection` has none, so its unknown kind is a raw + /// passthrough. They are reached by different paths, and the point here is + /// that both resolve correctly *in the same document* — neither mechanism + /// is standing in for the other. + /// + /// Both are exercised as unknowns deliberately. A known tool would let this + /// pass even if `Tool` → `CustomTool` were completely broken. + /// + /// On its own this establishes *coexistence*, not independence: it would + /// still pass if the wildcard only fired while an unknown connection happened + /// to be present. `testCustomToolResolvesWithoutAnUnknownConnection` closes + /// that direction; the two are only meaningful together. + func testUnknownConnectionIsIndependentOfCustomTool() throws { + let connection: [String: Any] = [ + "kind": "future-auth", "endpoint": "https://future.example.com", + ] + + let agent = try Prompty.load([ + "kind": "prompt", + "name": "independence", + "model": ["id": "gpt-4o-mini", "apiType": "chat", "connection": connection], + "tools": [ + ["name": "unknown_kind_tool", "kind": "some-future-tool", "description": "forward compat"], + [ + "name": "known_fn", "kind": "function", + "parameters": [["name": "city", "kind": "string"]], + ], + ], + "instructions": "user:\nhi", + ]) + + // Guard rather than assert: a plain count assertion does not stop the test, + // so a dropped tool would trap on the subscripts below instead of failing. + let tools = try XCTUnwrap(agent.tools) + guard tools.count == 2 else { + return XCTFail("a tool was dropped on load: \(tools)") + } + + // Subscripts are safe *here*: this list came straight from an input array, + // which preserves declaration order. The post-reload list below has no such + // guarantee, which is why it is searched by name instead. + // + // The unknown tool took the Tool wildcard path. + guard case .customTool(let custom) = tools[0] else { + return XCTFail("an unknown tool kind did not become a CustomTool: \(tools[0])") + } + XCTAssertEqual(custom.kind, "some-future-tool", "the tool discriminator was not preserved") + XCTAssertEqual(custom.name, "unknown_kind_tool") + + // The known tool was not swept up by that wildcard. + guard case .functionTool = tools[1] else { + return XCTFail("a known function tool was reclassified: \(tools[1])") + } + + // And the connection took its own, separate path. + guard case .unknown(let raw) = try XCTUnwrap(agent.model.connection) else { + return XCTFail("unknown connection inside a Prompty was not passed through") + } + XCTAssertEqual(raw["kind"] as? String, "future-auth") + XCTAssertEqual(raw["endpoint"] as? String, "https://future.example.com") + + // Both survive a document save/reload together. + // + // These lookups are by name on purpose: this test asserts *identity* — that + // both tools come back as themselves — and deliberately says nothing about + // reload order. A document save may key tools by name rather than emitting + // an ordered array, and a candidate emitter that does so was measured + // returning both tools intact but alphabetically re-sorted, which failed a + // positional `.first` check here for a reason unrelated to Connection/Tool + // independence. Whether object-form order is contractual is an open + // cross-runtime question; if it is ever ruled load-bearing it belongs in a + // dedicated ordering test, not smuggled into this one. So: keep these + // lookups by name, and do not "simplify" them back to subscripts. + let reloaded = try Prompty.load(try agent.save()) + let reloadedTools = try XCTUnwrap(reloaded.tools) + XCTAssertEqual(reloadedTools.count, 2, "a tool was dropped by the document round trip") + + let reloadedCustom = reloadedTools.first { tool in + if case .customTool(let candidate) = tool { return candidate.name == "unknown_kind_tool" } + return false + } + guard case .customTool(let roundTripped)? = reloadedCustom else { + return XCTFail("CustomTool did not survive the document round trip: \(reloadedTools)") + } + XCTAssertEqual( + roundTripped.kind, "some-future-tool", + "the tool discriminator was lost on reload") + + // The known tool survives as itself, so the wildcard did not widen to + // swallow it on the way back in. + XCTAssertTrue( + reloadedTools.contains { tool in + if case .functionTool(let candidate) = tool { return candidate.name == "known_fn" } + return false + }, + "a known function tool did not survive the document round trip: \(reloadedTools)") + + guard case .unknown(let reloadedRaw) = try XCTUnwrap(reloaded.model.connection) else { + return XCTFail("unknown connection did not survive the document round trip") + } + XCTAssertEqual(try canonical(reloadedRaw), try canonical(connection)) + } + + /// The `Tool` wildcard fires with an entirely ordinary connection present. + /// + /// This is the other half of `testUnknownConnectionIsIndependentOfCustomTool`. + /// That test shows both unknowns resolving side by side, which alone would + /// still pass if `CustomTool` resolution were somehow conditioned on an + /// unknown connection being in the document. Here the connection is a known, + /// fully typed `key` connection, so nothing unknown exists anywhere except + /// the tool kind — if the wildcard still fires and survives a round trip, the + /// two mechanisms genuinely do not depend on each other. + func testCustomToolResolvesWithoutAnUnknownConnection() throws { + let agent = try Prompty.load([ + "kind": "prompt", + "name": "tool-wildcard-alone", + "model": [ + "id": "gpt-4o-mini", "apiType": "chat", + "connection": ["kind": "key", "endpoint": "https://known.example.com", "apiKey": "sk-x"], + ], + "tools": [ + ["name": "unknown_kind_tool", "kind": "some-future-tool", "description": "forward compat"] + ], + "instructions": "user:\nhi", + ]) + + // Precondition: nothing about the connection is unknown, so a passing + // wildcard assertion below cannot be attributed to unknown-connection + // handling. A failure here means the fixture stopped testing what it says. + guard case .apiKeyConnection = try XCTUnwrap(agent.model.connection) else { + return XCTFail( + "fixture no longer isolates the tool wildcard — the connection is not a known key " + + "connection: \(String(describing: agent.model.connection))") + } + + let tools = try XCTUnwrap(agent.tools) + guard tools.count == 1, case .customTool(let custom) = tools[0] else { + return XCTFail("the Tool wildcard did not fire without an unknown connection: \(tools)") + } + XCTAssertEqual(custom.kind, "some-future-tool", "the tool discriminator was not preserved") + + let reloadedTools = try XCTUnwrap(try Prompty.load(try agent.save()).tools) + let survived = reloadedTools.first { tool in + if case .customTool(let candidate) = tool { return candidate.name == "unknown_kind_tool" } + return false + } + guard case .customTool(let roundTripped)? = survived else { + return XCTFail("CustomTool did not survive the document round trip: \(reloadedTools)") + } + XCTAssertEqual( + roundTripped.kind, "some-future-tool", + "the tool discriminator was lost on reload") + } + + /// An unknown connection nested in a Prompty survives a full document + /// save/reload, which is the path a real `.prompty` file takes. + func testUnknownConnectionSurvivesPromptyRoundTrip() throws { + let connection: [String: Any] = [ + "kind": "future-auth", + "endpoint": "https://future.example.com", + "nested": ["retained": true, "n": NSNull()], + ] + + let agent = try Prompty.load([ + "kind": "prompt", + "name": "nested", + "model": ["id": "gpt-4o-mini", "apiType": "chat", "connection": connection], + "instructions": "user:\nhi", + ]) + + let saved = try agent.save() + let reloaded = try Prompty.load(saved) + + let raw = try unknownPayload( + try XCTUnwrap(reloaded.model.connection), "connection after Prompty round trip") + XCTAssertEqual(try canonical(raw), try canonical(connection)) + } + + // MARK: - Tripwire + + /// Repoint the *portable* half of this suite at the shared vector once it + /// reaches this branch. + /// + /// The canonical cases live in + /// `spec/vectors/model/connection_roundtrip_vectors.json`, which was added on + /// a branch with no Swift runtime tree. When both land on one commit, drive + /// the three required cases from the vector, exactly as + /// `ToolBindingTests.testSharedBindingsInjectedVector` does. + /// + /// **Do not delete the inline payloads.** The canonical decision is that the + /// Swift dynamic-type assertions stay as runtime-specific coverage: they are + /// stronger than the portable contract, and deleting them on repoint would + /// lose real coverage. Concretely, after the repoint: + /// + /// - the shared vector drives ``canonical(_:)`` text comparison, which is the + /// portable promise — complete JSON-value preservation; + /// - ``assertSameShape(_:_:_:)`` **may** also run against shared-vector + /// payloads. Almost everything it checks — bool, null, integer, fractional + /// float, nesting — is portable, and the shared vector deliberately + /// contains no whole-valued float (it covers integer `priority: 5` and + /// fractional `weight`/`backoff: 0.1`), so nothing non-portable is imposed; + /// - only the **whole-valued float** stays inline-only. That single case is + /// the non-portable one: JavaScript and Go cannot preserve + /// integer-versus-whole-float, so requiring it portably would fail those + /// runtimes over a spelling difference the contract never promised. + /// + /// Keep ``futureAuthPayload()`` as the inline fixture even once a + /// vector-driven `future-auth` case exists. The two need not be kept in sync: + /// the vector case owns the portable assertions, while the inline payload + /// exists precisely to carry the extra whole-valued float that the vector + /// must not contain. + /// + /// ``testWholeValuedFloatCoverageIsRuntimeSpecific`` enforces that split, so + /// the coverage cannot silently evaporate during a repoint. + func testCanonicalVectorStillAbsent() throws { + let url = + Spec.root + .appendingPathComponent("vectors") + .appendingPathComponent("model") + .appendingPathComponent("connection_roundtrip_vectors.json") + + XCTAssertFalse( + FileManager.default.fileExists(atPath: url.path), + [ + "connection_roundtrip_vectors.json is now on this branch. Drive the", + "three canonical cases from it instead of the inline payloads, then", + "delete this tripwire. Keep futureAuthPayload and", + "testWholeValuedFloatCoverageIsRuntimeSpecific: only the whole-valued", + "float is non-portable and must stay inline-only. assertSameShape may", + "run against the shared vector, which contains no whole-valued float.", + ].joined(separator: " ")) + } + + /// Pin that whole-valued-float coverage exists and is Swift-only. + /// + /// The coordinator's decision has two halves: keep this assertion as + /// runtime-specific coverage, and keep it *out* of the portable contract. + /// Prose alone would not survive a repoint, so this asserts the coverage + /// mechanically: the inline payload must carry a whole-valued `Double`, and + /// it must still be a `Double` after a full load/save round trip. + /// + /// If a future repoint deletes the inline payload, this fails rather than + /// quietly dropping the only check that separates `3` from `3.0`. + func testWholeValuedFloatCoverageIsRuntimeSpecific() throws { + let payload = futureAuthPayload() + + let declared = try XCTUnwrap( + payload["floatWhole"], + "the inline payload must keep a whole-valued float; it is the only case " + + "that separates an int from an integral float") + XCTAssertTrue( + declared is Double, + "floatWhole must be declared as a Double, not \(type(of: declared))") + XCTAssertEqual(declared as? Double, 3.0) + + // The value survives the round trip as a Double, not collapsed to an Int. + // + // This pins the runtime's raw-dictionary passthrough, not JSON round-trip + // fidelity: `Connection.load` stores the unknown payload by casting it + // (`TypraRuntime.object` is a bare `as? [String: Any]`) and `.save()` + // returns that same dictionary, so no serialization boundary is crossed + // and the native `Double` is never bridged to `NSNumber`. That is exactly + // why `is Double` is reliable here. Proving fidelity *through* JSON is + // impossible by construction -- `3.0` collapses to `3` in the text -- which + // is the reason the runtime keeps raw dictionaries in the first place. + let reloaded = try Connection.load(payload).save() + let observed = try XCTUnwrap( + reloaded["floatWhole"], "whole-valued float dropped on round trip") + XCTAssertTrue( + observed is Double, + "whole-valued float came back as \(type(of: observed)); the unknown arm " + + "must hand back the original dictionary untouched") + + // Guard the other half of the split: this distinction is deliberately + // absent from the serialized form, which is what the shared vector + // compares. If these ever differ, the portable contract has drifted. + XCTAssertEqual( + try canonical(["v": 3.0]), try canonical(["v": 3]), + "an integral float and an int must remain indistinguishable in JSON " + + "text, since that is the level the portable vector asserts") + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/ContentPartDiscriminatorTests.swift b/runtime/swift/prompty/Tests/PromptyTests/ContentPartDiscriminatorTests.swift new file mode 100644 index 000000000..10dcde23b --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/ContentPartDiscriminatorTests.swift @@ -0,0 +1,405 @@ +import Foundation + +import PromptyModel + +import XCTest + +@testable import Prompty + +/// Strict-discriminator acceptance gate for `ContentPart`. +/// +/// This is the Swift half of the shared acceptance strengthened by Prompty +/// PR #447 commit `0c64ef33`, whose canonical vector is +/// `spec/vectors/model/content_part_discriminator_vectors.json`. +/// +/// That vector is **not checked out on this branch** — `spec/vectors/model/` +/// is absent here — but it does exist in repository history, added by commit +/// `b820d785` ("Define strict content part discriminators"). The three cases +/// below are transcribed from that commit *verbatim*, including payload fields +/// the discriminator logic never reads, so this suite cannot pass on a +/// simplified input that the real vector would reject. +/// +/// ``testCanonicalVectorWhenPresent`` drives the same assertions directly off +/// the vector file and activates automatically once it lands on this branch, +/// at which point the inline mirror becomes redundant and can be deleted. +/// +/// The contract under test, from the vector's own description — "ContentPart +/// is closed and case-sensitive: unknown kinds are rejected rather than +/// preserved like unknown Connection values or dispatched like unknown Tool +/// values": +/// +/// 1. `text` loads unchanged; +/// 2. `video` is rejected — it is not a member of the closed union; +/// 3. `Text` is rejected — matching is exact and case-sensitive; +/// 4. rejection surfaces a *structured* diagnostic exposing the field `kind` +/// and the offending raw value verbatim. +/// +/// `ContentPart` is **closed**: unlike `Connection` (which passes unknown +/// kinds through) and `Tool` (which dispatches them to `CustomTool`), it must +/// never gain a Swift `.unknown` case. All three policies are pinned against +/// each other in ``testClosedContentPartIsIndependentOfOpenUnions``. +/// +/// Closedness itself is enforced at *compile time* — see ``caseName(_:)``. +final class ContentPartDiscriminatorTests: XCTestCase { + + // MARK: - Canonical inputs, transcribed from commit b820d785 + + /// `known_text_content_part_loads` + private static let textInput: [String: Any] = ["kind": "text", "value": "hello"] + + /// `unknown_content_part_kind_is_rejected`. `durationSeconds` is carried + /// deliberately: a loader that accepted `video` only for a particular + /// payload shape would pass a stripped-down input but fail the real vector. + private static let videoInput: [String: Any] = [ + "kind": "video", + "source": "https://example.test/video.mp4", + "durationSeconds": 3, + ] + + /// `content_part_case_collision_is_rejected` + private static let capitalTextInput: [String: Any] = [ + "kind": "Text", "value": "case-sensitive", + ] + + // MARK: - Helpers + + /// Exhaustive switch with no `default:`. + /// + /// This is the closedness gate. If a future emitter adds an `.unknown` case + /// to `ContentPart`, this function stops compiling and the whole suite fails + /// to build — an earlier and louder signal than any runtime assertion. + /// + /// It is deliberately broader than "no `.unknown`": it freezes the case set, + /// so *any* added member breaks the build and forces a human decision. Two + /// other exhaustive switches (the generated `save()` and the runtime's + /// `OpenAIWire.part(_:)`) happen to break first today, but both are code an + /// emitter change could update automatically. This one cannot be updated + /// without editing a test, which is the review signal worth keeping. + /// + /// Do not add a `default:` branch here. + private func caseName(_ part: ContentPart) -> String { + switch part { + case .textPart: return "textPart" + case .imagePart: return "imagePart" + case .filePart: return "filePart" + case .audioPart: return "audioPart" + } + } + + /// Asserts that `raw` is rejected by `ContentPart.load` with the specific + /// structured diagnostic the acceptance requires. + /// + /// Deliberately stricter than `XCTAssertThrowsError`: a bare "it threw" + /// assertion would also pass for `invalidObject` or `invalidField`, so this + /// pattern-matches the exact error case and checks that the reported field is + /// `kind` and the reported value is the offending discriminator verbatim. + private func assertRejects( + _ raw: [String: Any], + expectedValue: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + do { + let part = try ContentPart.load(raw) + XCTFail( + "expected \(expectedValue) to be rejected, got \(caseName(part))", + file: file, line: line) + } catch { + assertDiagnostic(error, expectedValue: expectedValue, file: file, line: line) + } + } + + /// Shared diagnostic assertion, so nested-path tests hold the error to the + /// same standard as direct loads instead of merely checking that something + /// was thrown. + private func assertDiagnostic( + _ error: Error, + expectedValue: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard let typed = error as? TypraRuntimeError else { + XCTFail("expected TypraRuntimeError, got \(error)", file: file, line: line) + return + } + guard case .unknownDiscriminator(let type, let field, let value) = typed else { + XCTFail("expected .unknownDiscriminator, got \(typed)", file: file, line: line) + return + } + XCTAssertEqual( + type, "ContentPart", "diagnostic must name the type", file: file, line: line) + XCTAssertEqual(field, "kind", "diagnostic must expose the field", file: file, line: line) + XCTAssertEqual( + value, expectedValue, "diagnostic must expose the raw value verbatim", + file: file, line: line) + + // Foundation's `StringProtocol.contains` overload bridges to + // `NSString.range(of:)`, which reports `false` for an empty needle — the + // opposite of the stdlib overload's `true`. Both were verified on the + // toolchain in use. The check is therefore only meaningful, and only + // applied, for a non-empty discriminator. + if !expectedValue.isEmpty { + XCTAssertTrue( + typed.description.contains(expectedValue), + "rendered message must carry the raw value, got: \(typed.description)", + file: file, line: line) + } + } + + private func canonical(_ value: Any) throws -> String { + let data = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) + return String(decoding: data, as: UTF8.self) + } + + private var canonicalVectorURL: URL { + Spec.root.appendingPathComponent("vectors/model/content_part_discriminator_vectors.json") + } + + // MARK: - 1. text loads unchanged + + func testTextLoadsUnchanged() throws { + let raw = Self.textInput + let part = try ContentPart.load(raw) + + guard case .textPart(let text) = part else { + return XCTFail("expected .textPart, got \(caseName(part))") + } + XCTAssertEqual(text.kind, "text") + XCTAssertEqual(text.value, "hello") + + // "Unchanged" is a round-trip claim, not just a field claim. `TextPart` + // has exactly `kind` and `value`, so strict equality is safe — and a + // newly emitted or defaulted field *should* fail an "unchanged" gate. + XCTAssertEqual(try canonical(try part.save()), try canonical(raw)) + } + + func testTextRoundTripIsIdempotent() throws { + var current = try canonical(Self.textInput) + for pass in 0..<3 { + let object = try JSONSerialization.jsonObject(with: Data(current.utf8)) + let saved = try ContentPart.load(object).save() + XCTAssertEqual(try canonical(saved), current, "drift on pass \(pass)") + current = try canonical(saved) + } + } + + func testAllKnownKindsResolve() throws { + let cases: [(String, String)] = [ + ("text", "textPart"), + ("image", "imagePart"), + ("file", "filePart"), + ("audio", "audioPart"), + ] + for (kind, expected) in cases { + var raw: [String: Any] = ["kind": kind] + if kind == "text" { + raw["value"] = "v" + } else { + raw["source"] = "s" + } + let part = try ContentPart.load(raw) + XCTAssertEqual(caseName(part), expected, "kind \(kind)") + } + } + + // MARK: - 2. video rejects + + func testVideoIsRejected() { + assertRejects(Self.videoInput, expectedValue: "video") + } + + /// `video` must be rejected on the discriminator alone, regardless of how + /// plausible or sparse the rest of the payload is. Guards against a future + /// "recover by shape" heuristic quietly reopening the union. + func testVideoIsRejectedRegardlessOfPayloadShape() { + assertRejects(["kind": "video", "value": "looks like a text part"], expectedValue: "video") + assertRejects(["kind": "video"], expectedValue: "video") + } + + // MARK: - 3. Text rejects (case-sensitive) + + func testCapitalTextIsRejected() { + assertRejects(Self.capitalTextInput, expectedValue: "Text") + } + + /// The two payloads below differ *only* in the casing of the discriminator, + /// so exact, case-sensitive matching is the sole thing that can separate an + /// accepted load from a rejected one. + func testCapitalTextDiffersFromTextOnlyByCasing() throws { + let accepted: [String: Any] = ["kind": "text", "value": "case-sensitive"] + let rejected: [String: Any] = ["kind": "Text", "value": "case-sensitive"] + + XCTAssertNoThrow(try ContentPart.load(accepted)) + assertRejects(rejected, expectedValue: "Text") + } + + func testOtherCasingsAreAlsoRejected() { + for kind in ["TEXT", "Image", "IMAGE", "File", "Audio", "AUDIO", "tExT"] { + assertRejects(["kind": kind, "value": "v", "source": "s"], expectedValue: kind) + } + } + + func testAdjacentUnknownKindsAreRejected() { + for kind in [" text", "text ", "video/mp4", "textPart", "unknown"] { + assertRejects(["kind": kind, "value": "v"], expectedValue: kind) + } + } + + /// An explicitly empty discriminator is rejected and reported as `""`. + func testEmptyKindIsRejected() { + assertRejects(["kind": "", "value": "empty kind"], expectedValue: "") + } + + /// A *missing* `kind` is rejected too — that is the part of the behaviour + /// this pins, and all that the acceptance requires. + /// + /// The generated loader coalesces an absent value to `""` before dispatching + /// (`object["kind"] ?? ""`), so today it reports a missing field as though + /// the caller had written `kind: ""`. That is a diagnostic-quality wart: + /// there is no raw value to echo, so echoing `""` misrepresents the input. + /// It is reported upstream rather than frozen here — asserting the exact + /// `""` value would turn a future emitter *improvement* into a red test. + func testMissingKindIsRejected() { + XCTAssertThrowsError(try ContentPart.load(["value": "no kind at all"])) { error in + guard case .unknownDiscriminator(_, let field, _)? = error as? TypraRuntimeError else { + return XCTFail("expected .unknownDiscriminator, got \(error)") + } + XCTAssertEqual(field, "kind") + } + } + + // MARK: - 4. Rejection is not swallowed by nesting + + /// `ContentPart` is reached in practice through `Message.parts`. A union that + /// rejects in isolation but is skipped, defaulted or dropped when nested + /// would satisfy a naive gate while still losing data. + func testRejectionPropagatesThroughMessage() { + let raw: [String: Any] = [ + "role": "user", + "parts": [["kind": "text", "value": "fine"], Self.videoInput], + ] + XCTAssertThrowsError(try Message.load(raw)) { error in + self.assertDiagnostic(error, expectedValue: "video") + } + } + + /// The second nested path. + func testRejectionPropagatesThroughToolResult() { + let raw: [String: Any] = ["parts": [Self.capitalTextInput]] + XCTAssertThrowsError(try ToolResult.load(raw)) { error in + self.assertDiagnostic(error, expectedValue: "Text") + } + } + + /// Rejection has to win even when the invalid part is not the first element, + /// so a valid sibling cannot mask it. + func testRejectionIsNotMaskedByValidSiblings() { + let raw: [String: Any] = [ + "role": "assistant", + "parts": [ + ["kind": "text", "value": "one"], + ["kind": "image", "source": "two"], + Self.videoInput, + ], + ] + XCTAssertThrowsError(try Message.load(raw)) { error in + self.assertDiagnostic(error, expectedValue: "video") + } + } + + // MARK: - Closed ContentPart vs the open unions + + /// Pins all three unknown-kind policies against each other using the + /// *identical* payload, so no shape-based heuristic can satisfy this by + /// accident: `Connection` preserves it losslessly, `Tool` dispatches it to + /// `CustomTool`, and `ContentPart` refuses it outright. + /// + /// The Connection half asserts the *whole* passthrough dictionary, not just + /// the discriminator, because "lossless" is a claim about the entire payload. + /// + /// This is not the only thing keeping the policies apart — the rejection + /// tests above and `ConnectionRoundTripTests` each pin one side — but it is + /// the only place the contrast is asserted against one shared input. + func testClosedContentPartIsIndependentOfOpenUnions() throws { + let shared: [String: Any] = [ + "kind": "future-auth", + "name": "shared", + "endpoint": "https://example.test", + ] + + let connection = try Connection.load(shared) + guard case .unknown(let passthrough) = connection else { + return XCTFail("Connection must stay open and pass unknown kinds through") + } + XCTAssertEqual( + try canonical(passthrough), try canonical(shared), + "Connection passthrough must be lossless, not just discriminator-preserving") + + let tool = try Tool.load(shared) + guard case .customTool = tool else { + return XCTFail("Tool must dispatch unknown kinds to CustomTool") + } + + // Same input, opposite policy. + assertRejects(shared, expectedValue: "future-auth") + } + + /// `ContentPart` must not acquire a passthrough representation. The + /// compile-time gate is ``caseName(_:)``; this is the behavioural half. + func testUnknownContentPartNeverSurvivesLoad() { + for kind in ["video", "Text", "future-auth", "custom"] { + XCTAssertThrowsError( + try ContentPart.load(["kind": kind, "value": "v"]), + "\(kind) must not be representable" + ) { error in + self.assertDiagnostic(error, expectedValue: kind) + } + } + } + + // MARK: - Vector-driven run, active once the vector lands + + /// Drives the same contract straight off the canonical vector. + /// + /// Skips while `spec/vectors/model/` is absent from this branch; the inline + /// transcription above covers the identical cases in the meantime. When the + /// vector arrives this activates with no code change, and the inline mirror + /// can then be deleted. + func testCanonicalVectorWhenPresent() throws { + let url = canonicalVectorURL + guard FileManager.default.fileExists(atPath: url.path) else { + throw XCTSkip("canonical vector not on this branch; inline mirror covers the same cases") + } + + let data = try Data(contentsOf: url) + let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let vectors = root?["vectors"] as? [[String: Any]] ?? [] + XCTAssertFalse(vectors.isEmpty, "vector file declares no cases") + + for vector in vectors { + let name = vector["name"] as? String ?? "" + guard let input = vector["input"] as? [String: Any] else { + XCTFail("\(name): missing input") + continue + } + let expected = vector["expected"] as? [String: Any] ?? [:] + + switch vector["operation"] as? String { + case "load": + let part = try ContentPart.load(input) + XCTAssertEqual( + try canonical(try part.save()), try canonical(expected), + "\(name): loaded value must match expected") + + case "load-error": + XCTAssertEqual( + expected["discriminator"] as? String, "kind", + "\(name): vector expects a different field") + assertRejects(input, expectedValue: expected["value"] as? String ?? "") + + case let other: + XCTFail("\(name): unsupported operation \(other ?? "nil")") + } + } + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/GeneratedModelRoundTripTests.swift b/runtime/swift/prompty/Tests/PromptyTests/GeneratedModelRoundTripTests.swift new file mode 100644 index 000000000..4c9dd88c9 --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/GeneratedModelRoundTripTests.swift @@ -0,0 +1,275 @@ +import Foundation + +import PromptyModel + +import XCTest + +/// Guards the fields `schema/scripts/patch-swift-emitter-defects.mjs` restores. +/// +/// @typra/emitter@0.4.2 drops `extends` base-model fields from derived Swift +/// structs, so `ArrayProperty` / `ObjectProperty` / `UnionProperty` and every +/// `Tool` subtype silently lose data on load and save. The shim injects them +/// back. These tests fail loudly if that regresses — including if a future +/// emitter release fixes the defect differently. +@testable import Prompty + +final class GeneratedModelRoundTripTests: XCTestCase { + + // MARK: - Property subtypes + + /// Every base `Property` field must survive `load` → `save` on each subtype. + func testPropertyBaseFieldsRoundTripOnEverySubtype() throws { + let subtypes: [String: [String: Any]] = [ + "array": ["items": ["name": "item", "kind": "string"]], + "object": ["properties": [["name": "child", "kind": "string"]]], + "union": ["anyOf": [["name": "a", "kind": "string"]]], + ] + + for (kind, extra) in subtypes { + var source: [String: Any] = [ + "name": "field_\(kind)", + "kind": kind, + "description": "a \(kind) property", + "required": true, + "nullable": true, + "default": ["seeded"], + "example": ["sampled"], + "enumValues": ["one", "two"], + ] + source.merge(extra) { current, _ in current } + + let property = try Property.load(source) + let saved = try property.save() + + for key in [ + "name", "description", "required", "nullable", "default", "example", "enumValues", + ] { + XCTAssertTrue( + Spec.equal(saved[key], source[key]), + "\(kind) property lost '\(key)': got \(Spec.describe(saved[key])), expected \(Spec.describe(source[key]))" + ) + } + } + } + + /// The runtime reads base fields through `Property.raw`, so accessors must + /// see them too — not just the saved dictionary. + func testPropertyAccessorsSeeBaseFields() throws { + let property = try Property.load([ + "name": "choices", + "kind": "array", + "description": "pick one", + "required": true, + "nullable": true, + "items": ["name": "item", "kind": "string"], + ]) + + XCTAssertEqual(property.name, "choices") + XCTAssertEqual(property.kindName, "array") + XCTAssertEqual(property.propertyDescription, "pick one") + XCTAssertTrue(property.isRequired) + XCTAssertTrue(property.isNullable) + XCTAssertEqual(property.arrayItems?.kindName, "string") + } + + /// Nested subtypes must round-trip too — this is the case that motivated + /// marking the generated `Property` enum `indirect`. + func testNestedPropertySubtypesRoundTrip() throws { + let source: [String: Any] = [ + "name": "matrix", + "kind": "array", + "items": [ + "name": "row", + "kind": "array", + "items": ["name": "cell", "kind": "integer", "description": "a cell"], + ], + ] + + let property = try Property.load(source) + XCTAssertEqual(property.arrayItems?.name, "row") + XCTAssertEqual(property.arrayItems?.arrayItems?.name, "cell") + XCTAssertEqual(property.arrayItems?.arrayItems?.propertyDescription, "a cell") + XCTAssertTrue(Spec.equal(try property.save(), source)) + } + + // MARK: - Tool subtypes + + /// Every `Tool` subtype must retain the base `name` / `description`. + func testToolBaseFieldsRoundTripOnEverySubtype() throws { + let subtypes: [[String: Any]] = [ + ["kind": "function", "parameters": [["name": "city", "kind": "string"]]], + ["kind": "mcp", "serverName": "files"], + ["kind": "openapi", "specification": "./api.json"], + ["kind": "prompty", "path": "./child.prompty"], + // Unknown kinds fall through to the wildcard CustomTool case. + ["kind": "vendor_specific"], + ] + + for extra in subtypes { + var source: [String: Any] = [ + "name": "tool_\(extra["kind"] as? String ?? "?")", + "description": "a tool", + ] + source.merge(extra) { current, _ in current } + + let tool = try Tool.load(source) + XCTAssertEqual(tool.name, source["name"] as? String, "tool lost 'name'") + XCTAssertEqual(tool.toolDescription, "a tool", "tool lost 'description'") + + let saved = try tool.save() + XCTAssertTrue( + Spec.equal(saved["name"], source["name"]), + "\(source["kind"] ?? "?") tool did not save 'name'") + XCTAssertTrue( + Spec.equal(saved["description"], source["description"]), + "\(source["kind"] ?? "?") tool did not save 'description'") + } + } + + /// Bindings arrive either as a `Record` map — where the key supplies + /// the binding name — or as an already-named list. Both must load. + func testToolBindingsLoadFromMapForm() throws { + let tool = try Tool.load([ + "name": "lookup", + "kind": "function", + "parameters": [ + ["name": "query", "kind": "string"], + ["name": "tenant", "kind": "string"], + ], + "bindings": [ + "tenant": ["value": "contoso"], + "apiKey": ["value": "secret"], + ], + ]) + + // Map keys are sorted so generation stays deterministic. + XCTAssertEqual(Self.bindingNames(tool), ["apiKey", "tenant"]) + XCTAssertEqual(tool.boundParameterNames, ["apiKey", "tenant"]) + } + + func testToolBindingsLoadFromListForm() throws { + let tool = try Tool.load([ + "name": "lookup", + "kind": "function", + "bindings": [ + ["name": "tenant", "value": "contoso"] + ], + ]) + + XCTAssertEqual(Self.bindingNames(tool), ["tenant"]) + XCTAssertEqual(tool.boundParameterNames, ["tenant"]) + } + + // MARK: - Wildcard cases + + /// `Tool` and `Connection` both need a wildcard case so unknown kinds survive + /// a round trip instead of throwing. + func testUnknownToolKindRoundTrips() throws { + let source: [String: Any] = [ + "name": "vendor_tool", + "kind": "vendor.custom", + "options": ["setting": "value"], + ] + let saved = try Tool.load(source).save() + XCTAssertTrue( + Spec.equal(saved["kind"], "vendor.custom"), "unknown tool kind was not preserved") + XCTAssertTrue(Spec.equal(saved["name"], "vendor_tool")) + } + + func testUnknownConnectionKindRoundTrips() throws { + let source: [String: Any] = ["kind": "vendor.auth", "endpoint": "https://example.test"] + let saved = try Connection.load(source).save() + XCTAssertTrue(Spec.equal(saved, source), "unknown connection kind was not preserved") + } + + /// Characterizes a base-field gap the shim deliberately leaves open. + /// + /// `model Connection` declares `authenticationMode` and `usageDescription` + /// (`schema/model/connection/connection.tsp`), and every subtype `extends` + /// it — so the emitter defect that drops `Property` / `Tool` base fields + /// drops these too. The shim does not inject them; see the Defect 10 scope + /// note in `schema/scripts/patch-swift-emitter-defects.mjs`. + /// + /// The cost is real even though no runtime code reads the fields: both are + /// lost between `load` and `save` with zero compile diagnostics, which is + /// precisely the failure mode this file exists to catch. Covering all six + /// subtypes keeps the gap measured rather than assumed, and makes the test + /// fail if either field starts surviving — at which point re-audit the + /// emitter and this shim, then assert preservation instead. + func testConnectionBaseFieldsAreDroppedOnEverySubtype() throws { + let subtypes: [(kind: String, declared: [String: String])] = [ + ("reference", ["name": "my-connection", "target": "some-target"]), + ("remote", ["name": "my-connection", "endpoint": "https://example.test"]), + ("key", ["endpoint": "https://example.test", "apiKey": "secret"]), + ("anonymous", ["endpoint": "https://example.test"]), + ("oauth", ["endpoint": "https://example.test", "clientId": "client-id"]), + ("foundry", ["endpoint": "https://example.test", "name": "my-connection"]), + ] + + for (kind, declared) in subtypes { + var source: [String: Any] = ["kind": kind] + for (key, value) in declared { source[key] = value } + source["authenticationMode"] = "system" + source["usageDescription"] = "respond to email on your behalf" + + let loaded = try Connection.load(source) + // `.unknown` preserves its payload verbatim, so the assertions below + // would pass for the wrong reason if a discriminator stopped resolving. + if case .unknown = loaded { + XCTFail("\(kind) fell through to .unknown instead of its subtype") + continue + } + + let saved = try loaded.save() + XCTAssertEqual(saved["kind"] as? String, kind, "\(kind): discriminator lost") + for (key, value) in declared { + XCTAssertEqual(saved[key] as? String, value, "\(kind): declared field \(key) lost") + } + + XCTAssertNil( + saved["authenticationMode"], + "\(kind): authenticationMode now survives — re-audit the emitter and the " + + "shim, then replace this characterization with a preservation assertion") + XCTAssertNil( + saved["usageDescription"], + "\(kind): usageDescription now survives — re-audit the emitter and the " + + "shim, then replace this characterization with a preservation assertion") + } + } + + // MARK: - Helpers + + /// Read binding names off the loaded tool, which the generated `Tool` enum + /// exposes only through its raw payload. + /// Named collections serialize either as a name-keyed map — the default — or + /// as an already-named list when `collectionFormat` is `array`. Accept both, + /// so this pins binding *identity* rather than the emitter's chosen shape. + private static func bindingNames(_ tool: Tool) -> [String]? { + switch tool.raw["bindings"] { + case let list as [Any]: + return list.compactMap { ($0 as? [String: Any])?["name"] as? String }.sorted() + case let map as [String: Any]: + return map.keys.sorted() + default: + return nil + } + } + + // MARK: - Convenience factories + + /// The emitter's factories built messages from raw literals; the shim makes + /// them construct real enum values. + func testMessageFactoriesProduceTypedValues() { + let assistant = Message.assistant(text: "hi") + XCTAssertEqual(assistant.role.rawValue, "assistant") + XCTAssertEqual(assistant.textContent, "hi") + + let user = Message.user(text: "hello") + XCTAssertEqual(user.role.rawValue, "user") + XCTAssertEqual(user.textContent, "hello") + + let system = Message.system(text: "be helpful") + XCTAssertEqual(system.role.rawValue, "system") + XCTAssertEqual(system.textContent, "be helpful") + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/HarnessAdapterTests.swift b/runtime/swift/prompty/Tests/PromptyTests/HarnessAdapterTests.swift new file mode 100644 index 000000000..a254d9a3f --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/HarnessAdapterTests.swift @@ -0,0 +1,204 @@ +import Foundation + +import PromptyModel + +import XCTest + +/// Focused tests for the durability adapters. +/// +/// The replay vectors prove the *sequence* is right; these prove the adapters +/// behave correctly at their edges — where crash-durability, ordering, and +/// error containment actually live. +@testable import Prompty + +final class HarnessAdapterTests: XCTestCase { + + // MARK: - Journal + + /// Each record must be on disk before the call returns, so a crash mid-turn + /// still leaves a replayable prefix rather than an empty file. + func testJournalFlushesEachRecordImmediately() throws { + let path = Self.tempPath() + defer { try? FileManager.default.removeItem(atPath: path) } + let journal = JsonlEventJournalWriter(path: path) + + var first = TurnEvent(id: "e1", type: .turnStart, timestamp: "t") + first.iteration = 0 + _ = try journal.appendTurn(turnEvent: first) + + // Read back without closing — this is the crash case. + let midFlight = try JsonlEventJournalWriter.readRecords(path: path) + XCTAssertEqual(midFlight.count, 1) + XCTAssertEqual(midFlight[0]["kind"] as? String, "turn") + + var session = SessionEvent(id: "e2", type: .sessionEnd, timestamp: "t") + session.sessionId = "s1" + _ = try journal.appendSession(sessionEvent: session) + + var summary = SessionSummary(sessionId: "s1") + summary.turns = 1 + _ = try journal.close(summary: summary) + + let records = try JsonlEventJournalWriter.readRecords(path: path) + XCTAssertEqual(records.map { $0["kind"] as? String }, ["turn", "session", "summary"]) + } + + /// Closing twice must not append a second summary, or replay would see a + /// journal that ends after it already ended. + func testJournalCloseIsIdempotent() throws { + let path = Self.tempPath() + defer { try? FileManager.default.removeItem(atPath: path) } + let journal = JsonlEventJournalWriter(path: path) + + let summary = SessionSummary(sessionId: "s1") + _ = try journal.close(summary: summary) + _ = try journal.close(summary: summary) + + let records = try JsonlEventJournalWriter.readRecords(path: path) + XCTAssertEqual(records.filter { $0["kind"] as? String == "summary" }.count, 1) + } + + /// A journal must never interleave a record across lines, even when the + /// payload itself contains newlines. + func testJournalEscapesEmbeddedNewlines() throws { + let path = Self.tempPath() + defer { try? FileManager.default.removeItem(atPath: path) } + let journal = JsonlEventJournalWriter(path: path) + + var event = TurnEvent(id: "e1", type: .error, timestamp: "t") + event.payload = ["message": "line one\nline two"] + _ = try journal.appendTurn(turnEvent: event) + + let raw = try String(contentsOfFile: path, encoding: .utf8) + XCTAssertEqual(raw.split(separator: "\n", omittingEmptySubsequences: true).count, 1) + + let records = try JsonlEventJournalWriter.readRecords(path: path) + let payload = (records[0]["event"] as? [String: Any])?["payload"] as? [String: Any] + XCTAssertEqual(payload?["message"] as? String, "line one\nline two") + } + + // MARK: - Checkpoints + + /// Checkpoints must come back in creation order regardless of insertion + /// order, because replay walks them forward. + func testCheckpointsListInDeterministicOrder() async throws { + let store = InMemoryCheckpointStore() + + for index in [2, 0, 1] { + var checkpoint = Checkpoint(title: "cp\(index)") + checkpoint.id = "cp-\(index)" + checkpoint.sessionId = "s1" + checkpoint.checkpointNumber = Int32(index) + _ = try await store.save(checkpoint: checkpoint) + } + + let listed = try await store.listCheckpoints(sessionId: "s1") + XCTAssertEqual(listed.map { $0.id }, ["cp-0", "cp-1", "cp-2"]) + } + + /// Sessions must not read each other's checkpoints. + func testCheckpointsAreScopedPerSession() async throws { + let store = InMemoryCheckpointStore() + + for session in ["s1", "s2"] { + var checkpoint = Checkpoint(title: "cp") + checkpoint.id = "cp-\(session)" + checkpoint.sessionId = session + _ = try await store.save(checkpoint: checkpoint) + } + + let scoped = try await store.listCheckpoints(sessionId: "s1") + XCTAssertEqual(scoped.map { $0.id }, ["cp-s1"]) + let loaded = try await store.load(sessionId: "s2", checkpointId: "cp-s2") + XCTAssertEqual(loaded?.id, "cp-s2") + let crossSession = try await store.load(sessionId: "s1", checkpointId: "cp-s2") + XCTAssertNil(crossSession, "a checkpoint must not be readable from another session") + } + + // MARK: - Permissions + + func testPermissionResolversReportTheirDecision() async throws { + let request = PermissionRequest(permission: "tool:add") + + let allowed = try await AllowAllPermissionResolver().request(request: request) + XCTAssertTrue(allowed.approved) + XCTAssertEqual(allowed.permission, "tool:add") + + let denied = try await DenyAllPermissionResolver().request(request: request) + XCTAssertFalse(denied.approved) + XCTAssertEqual(denied.permission, "tool:add") + } + + // MARK: - Tool execution + + /// A tool executor must convert every failure into a result. If it threw, + /// one bad tool would abort the turn instead of letting the model recover. + func testToolExecutorContainsFailures() async throws { + let executor = FunctionHostToolExecutor(handlers: [ + "ok": { arguments in ["echo": arguments["value"] as Any] }, + "boom": { _ in throw InvokerError.execution("kaboom") }, + ]) + + var okRequest = HostToolRequest(toolName: "ok") + okRequest.arguments = ["value": 7] + let ok = try await executor.execute(request: okRequest) + XCTAssertTrue(ok.success) + XCTAssertNil(ok.errorKind) + XCTAssertEqual((ok.result as? [String: Any])?["echo"] as? Int, 7) + + let boom = try await executor.execute(request: HostToolRequest(toolName: "boom")) + XCTAssertFalse(boom.success) + XCTAssertEqual(boom.errorKind, "exception") + + let missing = try await executor.execute(request: HostToolRequest(toolName: "nope")) + XCTAssertFalse(missing.success) + XCTAssertEqual(missing.errorKind, "not_found") + } + + // MARK: - Replay verification + + func testVerifierDetectsLengthAndContentDrift() throws { + let verifier = ReferenceReplayVerifier() + + var a = ReplayJournalRecord() + a.kind = .turn + a.type = "turn:turn_start:0" + var b = ReplayJournalRecord() + b.kind = .turn + b.type = "turn:turn_end:1" + + let identical = try verifier.verify( + ReplayVerificationRequest(expected: [a, b], actual: [a, b])) + XCTAssertEqual(identical.status, .passed) + XCTAssertEqual(identical.mismatches?.isEmpty ?? true, true) + + let truncated = try verifier.verify( + ReplayVerificationRequest(expected: [a, b], actual: [a])) + XCTAssertEqual(truncated.status, .failed) + XCTAssertEqual(Int(truncated.expectedCount), 2) + XCTAssertEqual(Int(truncated.actualCount), 1) + + let divergent = try verifier.verify( + ReplayVerificationRequest(expected: [a, b], actual: [a, a])) + XCTAssertEqual(divergent.status, .failed) + XCTAssertEqual(divergent.mismatches?.first?.index, 1) + } + + // MARK: - Event sink + + func testCollectingSinkPreservesInterleavedOrder() throws { + let sink = CollectingEventSink() + + _ = try sink.emitSession( + sessionEvent: SessionEvent(id: "s1", type: .sessionStart, timestamp: "t")) + _ = try sink.emitTurn(turnEvent: TurnEvent(id: "t1", type: .turnStart, timestamp: "t")) + _ = try sink.emitTurn(turnEvent: TurnEvent(id: "t2", type: .turnEnd, timestamp: "t")) + + XCTAssertEqual(sink.turnEvents.map(\.id), ["t1", "t2"]) + XCTAssertEqual(sink.sessionEvents.map(\.id), ["s1"]) + } + + private static func tempPath() -> String { + NSTemporaryDirectory() + "prompty-journal-\(UUID().uuidString).jsonl" + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/InheritedPropertyFieldTests.swift b/runtime/swift/prompty/Tests/PromptyTests/InheritedPropertyFieldTests.swift new file mode 100644 index 000000000..47fadb2b6 --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/InheritedPropertyFieldTests.swift @@ -0,0 +1,469 @@ +import Foundation + +import PromptyModel + +import XCTest + +/// Acceptance gate for inherited-field data loss on composite `Property` subtypes. +/// +/// `ArrayProperty`, `ObjectProperty` and `UnionProperty` all `extends Property` +/// in `schema/model/core/properties.tsp`, so each inherits six fields: +/// `description`, `required`, `nullable`, `default`, `example` and `enumValues`. +/// A seventh, `name`, arrives through the `Named` alias rather than +/// through `extends`, but the emitter drops it the same way, so it is guarded +/// alongside them. @typra/emitter drops these fields from derived Swift structs, +/// which loses the values *silently* — the code compiles and the data just +/// disappears. `schema/scripts/patch-swift-emitter-defects.mjs` injects them +/// back into the stored properties, `load` and `save`. +/// +/// `GeneratedModelRoundTripTests` already covers the in-memory `load` → `save` +/// pair for these subtypes. This file extends that guarantee to the axes the +/// shared spec vectors never reach: JSON and YAML text round trips, values +/// constructed in Swift rather than parsed, non-scalar values in `default` / +/// `example` / `enumValues`, and inherited fields on nested branches. +/// +/// These tests are meant to survive the emitter fix: they assert the required +/// *behaviour*, not the shim. They deliberately avoid whole-dictionary equality, +/// so that unrelated additions to a saved payload do not fail a gate about +/// inherited fields. They do *not* tolerate a materialized schema default +/// standing in for an absent value: the canonical rule is that absent optional +/// collections stay omitted and save never synthesizes an empty one, so +/// `enumValues: []` appearing where the source wrote nothing is a violation +/// rather than an equivalent. `OptionalCollectionPresenceTests` pins that rule. +/// +/// Known residual gap, deliberately not asserted here: the shim restores base +/// fields to the stored properties but not to the generated memberwise `init`, +/// which only the emitter can do. Callers must therefore construct a composite +/// subtype and then assign inherited fields, as +/// `testInheritedFieldsSurviveProgrammaticConstruction` does. The shim's +/// `assertPinnedEmitterVersion()` already fails the build on any emitter version +/// change, so that gap gets re-audited without a canary test here. +@testable import Prompty + +final class InheritedPropertyFieldTests: XCTestCase { + + /// Every field `Property` passes down to its composite subtypes. + private static let inheritedFields = [ + "name", "description", "required", "nullable", "default", "example", "enumValues", + ] + + /// The discriminator-specific payload each composite subtype requires, + /// keyed by `kind`. + private static let compositePayloads: [String: [String: Any]] = [ + "array": ["items": ["name": "item", "kind": "string"]], + "object": ["properties": [["name": "child", "kind": "string"]]], + "union": ["oneOf": [["name": "branch", "kind": "string"]]], + ] + + /// A composite property carrying a value for every inherited field. + private static func source(kind: String, extra: [String: Any] = [:]) -> [String: Any] { + var source: [String: Any] = [ + "name": "field_\(kind)", + "kind": kind, + "description": "an inherited description on a \(kind) property", + "required": true, + "nullable": true, + "default": "default_\(kind)", + "example": "example_\(kind)", + "enumValues": ["one", "two"], + ] + source.merge(compositePayloads[kind] ?? [:]) { current, _ in current } + source.merge(extra) { _, replacement in replacement } + return source + } + + /// Assert every inherited field in `expected` survived into `actual`. + private func assertInheritedFieldsPreserved( + _ actual: [String: Any], + _ expected: [String: Any], + _ label: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + for key in Self.inheritedFields { + // An earlier revision exempted an empty `enumValues` standing in for an + // absent one, assuming a corrected emitter would materialize the schema + // default. The canonical rule inverts that: optional collection presence + // is semantic, so an absent collection must stay omitted and save must + // never synthesize an empty one from absent input. Materializing the + // default *is* the defect, so the exemption would have let a rule + // violation pass silently here. `OptionalCollectionPresenceTests` pins + // the rule directly; this assertion is now unconditional. + XCTAssertTrue( + Spec.equal(actual[key], expected[key]), + """ + \(label) lost inherited field '\(key)': \ + got \(Spec.describe(actual[key])), expected \(Spec.describe(expected[key])) + """, + file: file, + line: line + ) + } + } + + /// Named collections save either as a name-keyed map — the default, where the + /// entry name is the key and no longer repeated in the payload — or as an + /// already-named list. Read the sole entry from either shape, re-injecting the + /// key as `name`, so these assertions pin inherited *fields* rather than the + /// emitter's chosen collection form. Returns nil unless the collection holds + /// exactly one entry, so a multi-entry source fails loudly instead of + /// silently asserting against a positionally- vs lexicographically-chosen + /// element. + private static func soleNamedEntry(_ value: Any?) -> [String: Any]? { + switch value { + case let list as [Any]: + guard list.count == 1 else { return nil } + return list.first as? [String: Any] + case let map as [String: Any]: + guard map.count == 1, let key = map.keys.first, + var entry = map[key] as? [String: Any] + else { return nil } + if entry["name"] == nil { entry["name"] = key } + return entry + default: + return nil + } + } + + // MARK: - Serialized round trips + + /// JSON text is how a `.prompty` frontmatter property reaches other runtimes, + /// so inherited fields must survive the encode/decode pair — not just the + /// in-memory `load`/`save` pair the sibling suite covers. + func testInheritedFieldsSurviveJSONTextRoundTrip() throws { + for kind in Self.compositePayloads.keys.sorted() { + let source = Self.source(kind: kind) + let json = try Property.load(source).toJSON() + let saved = try Property.fromJSON(json).save() + assertInheritedFieldsPreserved(saved, source, "\(kind) property (JSON)") + } + } + + /// YAML is the on-disk frontmatter format, so it needs the same guarantee. + func testInheritedFieldsSurviveYAMLTextRoundTrip() throws { + for kind in Self.compositePayloads.keys.sorted() { + let source = Self.source(kind: kind) + let yaml = try Property.load(source).toYAML() + let saved = try Property.fromYAML(yaml).save() + assertInheritedFieldsPreserved(saved, source, "\(kind) property (YAML)") + } + } + + /// Repeated round trips must reach a fixed point. A subtype that drops an + /// inherited field only on the second pass would still look correct to a + /// single-pass assertion. Each pass is compared against the previous pass in + /// full, so drift in *any* field is caught, not just the inherited ones. + func testInheritedFieldsAreStableAcrossRepeatedRoundTrips() throws { + for kind in Self.compositePayloads.keys.sorted() { + let source = Self.source(kind: kind) + var previous = try Property.load(source).save() + assertInheritedFieldsPreserved(previous, source, "\(kind) property (pass 0)") + + for pass in 1...3 { + let saved = try Property.load(previous).save() + assertInheritedFieldsPreserved(saved, source, "\(kind) property (pass \(pass))") + XCTAssertTrue( + Spec.equal(saved, previous), + """ + \(kind) property drifted on pass \(pass): \ + got \(Spec.describe(saved)), previous pass was \(Spec.describe(previous)) + """ + ) + previous = saved + } + } + } + + // MARK: - Programmatic construction + + /// Values set on a Swift-constructed subtype must reach the wire. This is the + /// axis the memberwise-`init` gap sits on: if the emitter drops the stored + /// properties again, these assignments stop compiling instead of silently + /// vanishing, which is the failure mode we want. + func testInheritedFieldsSurviveProgrammaticConstruction() throws { + var array = ArrayProperty(items: .unknown(["name": "item", "kind": "string"])) + array.name = "built_array" + array.description = "built in Swift" + array.required = true + array.nullable = true + array.default = "fallback" + array.example = "sample" + array.enumValues = ["one", "two"] + + var object = ObjectProperty(properties: [.unknown(["name": "child", "kind": "string"])]) + object.name = "built_object" + object.description = "built in Swift" + object.required = true + object.nullable = true + object.default = "fallback" + object.example = "sample" + object.enumValues = ["one", "two"] + + var union = UnionProperty(oneOf: [.unknown(["name": "branch", "kind": "string"])]) + union.name = "built_union" + union.description = "built in Swift" + union.required = true + union.nullable = true + union.default = "fallback" + union.example = "sample" + union.enumValues = ["one", "two"] + + let cases: [(String, [String: Any])] = [ + ("array", try array.save()), + ("object", try object.save()), + ("union", try union.save()), + ] + + for (kind, saved) in cases { + let expected = Self.source( + kind: kind, + extra: [ + "name": "built_\(kind)", + "description": "built in Swift", + "default": "fallback", + "example": "sample", + ] + ) + assertInheritedFieldsPreserved(saved, expected, "constructed \(kind) property") + + // And the constructed value must survive a reload unchanged. + let reloaded = try Property.load(saved).save() + assertInheritedFieldsPreserved(reloaded, expected, "reloaded \(kind) property") + } + } + + // MARK: - Non-scalar inherited values + + /// The shared spec vectors only ever put scalars in `default` / `example` / + /// `enumValues`. Composite subtypes are exactly where structured values show + /// up in practice, so exercise them explicitly. + func testNonScalarInheritedValuesSurviveRoundTrip() throws { + let structured: [String: Any] = [ + "default": ["nested": ["deep": [1, 2, 3]], "flag": true], + "example": [["id": 1, "label": "first"], ["id": 2, "label": "second"]], + "enumValues": [["tier": "gold"], ["tier": "silver"]], + ] + + for kind in Self.compositePayloads.keys.sorted() { + let source = Self.source(kind: kind, extra: structured) + + let saved = try Property.load(source).save() + assertInheritedFieldsPreserved(saved, source, "\(kind) property (structured)") + + // Structured values must survive serialization too, not just the + // dictionary path — JSON is where type coercion tends to flatten them. + let json = try Property.load(source).toJSON() + let decoded = try Property.fromJSON(json).save() + assertInheritedFieldsPreserved(decoded, source, "\(kind) property (structured, JSON)") + } + } + + // MARK: - Nested composites + + /// Object children each carry their own inherited fields. A subtype that only + /// restored base fields at the top level would pass every other test here. + func testInheritedFieldsSurviveOnNestedObjectChildren() throws { + let source: [String: Any] = [ + "name": "envelope", + "kind": "object", + "description": "outer", + "required": true, + "properties": [ + [ + "name": "inner_array", + "kind": "array", + "description": "a nested array", + "required": true, + "nullable": true, + "default": ["seeded"], + "example": ["sampled"], + "enumValues": ["one", "two"], + "items": ["name": "cell", "kind": "string", "description": "a cell", "required": true], + ] + ], + ] + + let property = try Property.load(source) + let child = try XCTUnwrap(property.objectProperties.first) + + XCTAssertEqual(child.name, "inner_array") + XCTAssertEqual(child.propertyDescription, "a nested array") + XCTAssertTrue(child.isRequired) + XCTAssertTrue(child.isNullable) + XCTAssertEqual(child.arrayItems?.propertyDescription, "a cell") + + // Assert the child's inherited fields on the saved payload rather than + // comparing whole dictionaries, so newly-materialized schema defaults from + // a corrected emitter don't fail this for the wrong reason. + let saved = try property.save() + assertInheritedFieldsPreserved(saved, source, "outer object property") + + let savedChild = try XCTUnwrap( + Self.soleNamedEntry(saved["properties"]), "outer object lost its 'properties' entirely") + let expectedChild = try XCTUnwrap(Self.soleNamedEntry(source["properties"])) + assertInheritedFieldsPreserved(savedChild, expectedChild, "nested array child") + + let savedItems = try XCTUnwrap( + savedChild["items"] as? [String: Any], "nested array child lost its 'items'") + let expectedItems = try XCTUnwrap(expectedChild["items"] as? [String: Any]) + assertInheritedFieldsPreserved(savedItems, expectedItems, "nested array item") + } + + /// Union branches are properties in their own right, so each branch must keep + /// its inherited fields through a round trip. `oneOf` and `anyOf` are separate + /// generated fields and are checked separately. + func testInheritedFieldsSurviveOnUnionBranches() throws { + let branch: [String: Any] = [ + "name": "branch", + "kind": "array", + "description": "a union branch", + "required": true, + "nullable": true, + "default": ["seeded"], + "example": ["sampled"], + "enumValues": ["one", "two"], + "items": ["name": "item", "kind": "string", "description": "an item"], + ] + + for composition in ["oneOf", "anyOf"] { + let source: [String: Any] = [ + "name": "choice", + "kind": "union", + "description": "outer union", + "required": true, + composition: [branch], + ] + + let saved = try Property.load(source).save() + assertInheritedFieldsPreserved(saved, source, "outer union ('\(composition)')") + + let branches = try XCTUnwrap( + saved[composition] as? [Any], + "union lost its '\(composition)' branches entirely" + ) + let first = try XCTUnwrap(branches.first as? [String: Any]) + assertInheritedFieldsPreserved(first, branch, "union '\(composition)' branch") + + // The branch's own nested item must keep its inherited fields too. + let items = try XCTUnwrap( + first["items"] as? [String: Any], "union '\(composition)' branch lost its 'items'") + let expectedItems = try XCTUnwrap(branch["items"] as? [String: Any]) + assertInheritedFieldsPreserved(items, expectedItems, "union '\(composition)' branch item") + + // Only the populated composition field may be emitted. + let other = composition == "oneOf" ? "anyOf" : "oneOf" + XCTAssertNil(saved[other], "union emitted '\(other)' alongside '\(composition)'") + } + } + + /// A composite nested inside a composite — three levels of the recursive + /// `Property` shape. `Property` nests without limit, so this is a + /// representative depth, not an exhaustive one; it guards against a fix that + /// only walks one level down. + func testInheritedFieldsSurviveDeeplyNestedComposites() throws { + let source: [String: Any] = [ + "name": "rows", + "kind": "array", + "description": "outer array", + "required": true, + "items": [ + "name": "row", + "kind": "object", + "description": "a row", + "nullable": true, + "properties": [ + [ + "name": "values", + "kind": "union", + "description": "a cell value", + "required": true, + "enumValues": ["one", "two"], + "anyOf": [ + ["name": "as_text", "kind": "string", "description": "text form", "required": true] + ], + ] + ], + ], + ] + + let property = try Property.load(source) + let row = try XCTUnwrap(property.arrayItems) + XCTAssertEqual(row.propertyDescription, "a row") + XCTAssertTrue(row.isNullable) + + let cell = try XCTUnwrap(row.objectProperties.first) + XCTAssertEqual(cell.propertyDescription, "a cell value") + XCTAssertTrue(cell.isRequired) + XCTAssertTrue(Spec.equal(cell.enumValues, ["one", "two"])) + + // Walk the saved payload level by level, asserting inherited fields at each + // depth instead of comparing whole dictionaries. + let saved = try property.save() + assertInheritedFieldsPreserved(saved, source, "level 1 (array)") + + let savedRow = try XCTUnwrap(saved["items"] as? [String: Any], "level 1 lost 'items'") + let expectedRow = try XCTUnwrap(source["items"] as? [String: Any]) + assertInheritedFieldsPreserved(savedRow, expectedRow, "level 2 (object)") + + let savedCell = try XCTUnwrap( + Self.soleNamedEntry(savedRow["properties"]), "level 2 lost 'properties'") + let expectedCell = try XCTUnwrap(Self.soleNamedEntry(expectedRow["properties"])) + assertInheritedFieldsPreserved(savedCell, expectedCell, "level 3 (union)") + + let savedBranch = try XCTUnwrap( + (savedCell["anyOf"] as? [Any])?.first as? [String: Any], "level 3 lost 'anyOf'") + let expectedBranch = try XCTUnwrap((expectedCell["anyOf"] as? [Any])?.first as? [String: Any]) + assertInheritedFieldsPreserved(savedBranch, expectedBranch, "level 4 (union branch)") + } + + // MARK: - Falsy and explicitly-null inherited values + + /// Inherited fields set to their *default-looking* values must still survive. + /// `save()` guards each field differently — `name` is dropped when empty while + /// `required` is emitted whenever non-nil — so a field carrying `false`, `""` + /// or `[]` is the case most likely to be quietly discarded. + func testFalsyInheritedValuesSurviveRoundTrip() throws { + for kind in Self.compositePayloads.keys.sorted() { + let source = Self.source( + kind: kind, + extra: [ + "required": false, + "nullable": false, + "default": "", + "example": 0, + "enumValues": [], + ] + ) + + let saved = try Property.load(source).save() + for key in ["required", "nullable", "default", "example", "enumValues"] { + XCTAssertNotNil(saved[key], "\(kind) property dropped falsy inherited field '\(key)'") + XCTAssertTrue( + Spec.equal(saved[key], source[key]), + """ + \(kind) property corrupted falsy inherited field '\(key)': \ + got \(Spec.describe(saved[key])), expected \(Spec.describe(source[key])) + """ + ) + } + } + } + + /// Structured values must survive YAML as well as JSON — YAML is the on-disk + /// frontmatter format, and block/flow collection handling is a separate code + /// path from JSON encoding. + func testNonScalarInheritedValuesSurviveYAMLRoundTrip() throws { + let structured: [String: Any] = [ + "default": ["nested": ["deep": [1, 2, 3]], "flag": true], + "example": [["id": 1, "label": "first"], ["id": 2, "label": "second"]], + "enumValues": [["tier": "gold"], ["tier": "silver"]], + ] + + for kind in Self.compositePayloads.keys.sorted() { + let source = Self.source(kind: kind, extra: structured) + let yaml = try Property.load(source).toYAML() + let saved = try Property.fromYAML(yaml).save() + assertInheritedFieldsPreserved(saved, source, "\(kind) property (structured, YAML)") + } + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/LineEndingTests.swift b/runtime/swift/prompty/Tests/PromptyTests/LineEndingTests.swift new file mode 100644 index 000000000..02cc11a4e --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/LineEndingTests.swift @@ -0,0 +1,374 @@ +import Foundation +import PromptyModel +import XCTest + +/// Guards for Windows line endings. +/// +/// Swift treats a CRLF pair as a single `Character`, so splitting text on a +/// literal `"\n"` does not split a CRLF document at all — it returns the whole +/// thing as one line. Rust splits at byte level and degrades gracefully, so +/// this is a Swift-specific hazard with no counterpart in the reference +/// implementation, and nothing in the LF-only spec vectors can catch it. +@testable import Prompty +@testable import PromptyOpenAI + +final class LineEndingTests: XCTestCase { + + /// A prompt assembled in memory with Windows line endings must parse. + /// + /// The loader normalizes files it reads, but a prompt built from a dictionary + /// never passes through it, so every role marker was missed and parsing + /// produced one lumped message. + func testParserHandlesCarriageReturnLineEndings() async throws { + let agent = try Prompty.load([ + "kind": "prompt", + "name": "crlf", + "instructions": "system:\r\nYou are helpful.\r\n\r\nuser:\r\nHello there.", + ]) + + let messages = try await Pipeline.prepare(agent) + XCTAssertEqual(messages.map(\.role.rawValue), ["system", "user"]) + XCTAssertEqual(Self.text(messages[0].parts), "You are helpful.") + XCTAssertEqual(Self.text(messages[1].parts), "Hello there.") + } + + /// CRLF and LF forms of the same prompt must produce identical messages. + func testCarriageReturnMatchesUnixParse() throws { + let unix = "system:\nOne.\n\nuser:\nTwo.\n\nassistant:\nThree." + let windows = unix.replacingOccurrences(of: "\n", with: "\r\n") + + let expected = PromptyChatParser.parseChat(unix) + let actual = PromptyChatParser.parseChat(windows) + + XCTAssertEqual(expected.count, 3) + XCTAssertEqual(actual.map(\.role.rawValue), expected.map(\.role.rawValue)) + XCTAssertEqual(actual.map { Self.text($0.parts) }, expected.map { Self.text($0.parts) }) + } + + /// Role markers must still be found after `preRender` rewrites the template. + func testPreRenderRewritesCarriageReturnMarkers() throws { + let parser = PromptyChatParser() + let result = try parser.preRender(template: "system:\r\nOne.\r\n\r\nuser:\r\nTwo.") + let prepared = try XCTUnwrap(result as? PreRenderResult) + + let nonce = try XCTUnwrap(prepared.context["nonce"] as? String) + XCTAssertTrue(prepared.text.contains("system[nonce=\"\(nonce)\"]:")) + XCTAssertTrue(prepared.text.contains("user[nonce=\"\(nonce)\"]:")) + } + + /// A journal written with CRLF endings must replay. + /// + /// `readRecords` split on a literal `"\n"`, so a CRLF journal parsed as a + /// single unparseable line and silently verified as empty. + func testJournalReadsCarriageReturnRecords() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("prompty-crlf-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let path = directory.appendingPathComponent("journal.jsonl") + let journal = #"{"type":"session_start"}"# + "\r\n" + #"{"type":"session_end"}"# + "\r\n" + try journal.write(to: path, atomically: true, encoding: .utf8) + + let records = try JsonlEventJournalWriter.readRecords(path: path.path) + XCTAssertEqual(records.count, 2) + XCTAssertEqual(records.map { $0["type"] as? String }, ["session_start", "session_end"]) + } + + /// A journal record containing U+2028 must survive a round trip. + /// + /// `Character.isNewline` matches U+2028, U+2029 and U+0085, all of which are + /// legal *inside* a JSON string. Splitting on them tore a record in half and + /// left both pieces unparseable, so the record was silently discarded. + func testJournalKeepsUnicodeLineSeparatorsInsideRecords() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("prompty-u2028-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let path = directory.appendingPathComponent("journal.jsonl") + // U+2028 LINE SEPARATOR and U+0085 NEXT LINE, unescaped inside the value. + let payload = "before\u{2028}after\u{0085}end" + let journal = "{\"type\":\"note\",\"text\":\"\(payload)\"}\n{\"type\":\"session_end\"}\n" + try journal.write(to: path, atomically: true, encoding: .utf8) + + let records = try JsonlEventJournalWriter.readRecords(path: path.path) + XCTAssertEqual(records.count, 2) + XCTAssertEqual(records[0]["text"] as? String, payload) + } + + /// A lone CR is content, not a line ending. + /// + /// The reference loader normalizes `\r\n` and nothing else, so rewriting a + /// bare CR would alter legitimate message text and diverge from every other + /// runtime. + func testLoneCarriageReturnIsPreservedAsContent() throws { + let messages = PromptyChatParser.parseChat("user:\nbefore\rafter") + + XCTAssertEqual(messages.count, 1) + XCTAssertEqual(Self.text(messages[0].parts), "before\rafter") + } + + /// Splitting recognises the three real terminators and no others. + func testLineSplitterRecognisesOnlyCarriageReturnAndLineFeed() { + XCTAssertEqual(Lines.split("a\nb\r\nc\rd"), ["a", "b", "c", "d"]) + XCTAssertEqual(Lines.split("a\u{2028}b"), ["a\u{2028}b"]) + XCTAssertEqual(Lines.split("a\u{0085}b"), ["a\u{0085}b"]) + XCTAssertEqual(Lines.split("a\n\n\nb"), ["a", "b"]) + } + + /// A CR immediately before a CRLF must not hide the terminator. + /// + /// Normalizing and then splitting on characters is not enough: rewriting + /// `"a\r\r\nb"` once yields `"a\r\nb"`, whose CR and LF are now adjacent and + /// collapse into a single grapheme, so a character-level split misses it + /// again. The scalar scanner has no such blind spot. + func testAdjacentCarriageReturnsStillSplit() { + // `split` treats a lone CR as a terminator, `splitLineFeeds` as content — + // but neither may miss the CRLF that follows it. Before the scalar scanner + // both returned the whole string as a single line. + XCTAssertEqual(Lines.split("a\r\r\nb"), ["a", "b"]) + XCTAssertEqual(Lines.splitLineFeeds("a\r\r\nb"), ["a\r", "b"]) + + let messages = PromptyChatParser.parseChat("system:\r\r\nOne.\r\r\n\r\r\nuser:\r\r\nTwo.") + XCTAssertEqual(messages.map(\.role.rawValue), ["system", "user"]) + } + + /// Empty, terminator-only, and trailing-terminator inputs. + /// + /// `splitLineFeeds` stands in for `components(separatedBy:)` in the parser, so + /// it has to agree with it on the trailing empty segment or turn content + /// silently changes shape. + func testLineSplitterEdgeCases() { + XCTAssertEqual(Lines.split(""), []) + XCTAssertEqual(Lines.split("\n\r\n\r"), []) + XCTAssertEqual(Lines.split("a\n"), ["a"]) + + XCTAssertEqual(Lines.splitLineFeeds(""), [""]) + XCTAssertEqual(Lines.splitLineFeeds("a\n"), ["a", ""]) + XCTAssertEqual(Lines.splitLineFeeds("a\r\n"), ["a", ""]) + // Agreement with the spelling it replaced, for LF-only text. + for sample in ["", "a", "a\nb", "a\n", "\na", "a\n\nb"] { + XCTAssertEqual( + Lines.splitLineFeeds(sample), sample.components(separatedBy: "\n"), + "diverged from components(separatedBy:) for \(sample.debugDescription)") + } + } + + /// A lone CR must not be promoted to a terminator by normalization. + func testNormalizeRewritesOnlyCarriageReturnLineFeed() { + XCTAssertEqual(Lines.normalizeCRLF("a\r\nb"), "a\nb") + XCTAssertEqual(Lines.normalizeCRLF("a\rb"), "a\rb") + XCTAssertEqual(Lines.normalizeCRLF("a\r\r\nb"), "a\r\nb") + XCTAssertEqual(Lines.normalizeCRLF("a\n\rb"), "a\n\rb") + XCTAssertEqual(Lines.normalizeCRLF("plain"), "plain") + } + + private static func text(_ parts: [ContentPart]) -> String { + parts.compactMap { part -> String? in + if case .textPart(let text) = part { return text.value } + return nil + }.joined() + } + + // MARK: - Frontmatter + + /// `Frontmatter.split` must not depend on its caller having normalized first. + /// + /// It scanned with `firstIndex(of: "\n")`, which never matches in a CRLF + /// document because the pair is one `Character`. The opening delimiter looked + /// unterminated, so both the frontmatter and the body came back empty and a + /// Windows-authored file was silently discarded with no error. + func testFrontmatterSplitHandlesCarriageReturnDelimiters() throws { + let raw = "---\r\nname: crlf\r\ndescription: windows\r\n---\r\nsystem:\r\nHello.\r\n" + let (frontmatter, body) = try Frontmatter.split(raw) + + XCTAssertEqual(frontmatter["name"] as? String, "crlf") + XCTAssertEqual(frontmatter["description"] as? String, "windows") + XCTAssertEqual(body, "system:\nHello.\n") + } + + /// The `+++` delimiter and an indented opener take the same scalar path. + func testFrontmatterSplitHandlesCarriageReturnAlternateDelimiters() throws { + let (frontmatter, body) = try Frontmatter.split("+++\r\nname: toml-style\r\n+++\r\nBody.") + XCTAssertEqual(frontmatter["name"] as? String, "toml-style") + XCTAssertEqual(body, "Body.") + + let indented = try Frontmatter.split("\r\n\r\n ---\r\nname: indented\r\n --- \r\nBody.") + XCTAssertEqual(indented.frontmatter["name"] as? String, "indented") + XCTAssertEqual(indented.body, "Body.") + } + + /// Blank-line skipping must use `isWhitespace`, not `CharacterSet.whitespaces`. + /// + /// `.whitespaces` is horizontal only: vertical tab, form feed, NEL, and the + /// Unicode separators are excluded. Treating such a line as non-blank would + /// make the opener unreachable and silently demote the file to body-only. + func testFrontmatterSplitSkipsVerticalWhitespaceBeforeDelimiter() throws { + for blank in ["\u{000B}", "\u{000C}", "\u{0085}", "\u{2028}", "\u{2029}"] { + let (frontmatter, body) = try Frontmatter.split( + "\(blank)\n---\nname: after-blank\n---\nBody.") + XCTAssertEqual( + frontmatter["name"] as? String, "after-blank", + "U+\(String(format: "%04X", blank.unicodeScalars.first!.value)) should count as blank") + XCTAssertEqual(body, "Body.") + } + } + + /// CRLF and LF forms of one document must split identically. + func testFrontmatterSplitCarriageReturnMatchesUnix() throws { + let unix = "---\nname: same\nmodel:\n id: gpt-4o\n---\nsystem:\nOne.\n\nuser:\nTwo.\n" + let windows = unix.replacingOccurrences(of: "\n", with: "\r\n") + + let expected = try Frontmatter.split(unix) + let actual = try Frontmatter.split(windows) + + XCTAssertEqual(actual.frontmatter["name"] as? String, expected.frontmatter["name"] as? String) + XCTAssertEqual(actual.body, expected.body) + } + + /// A document with no frontmatter is still line-ending normalized. + /// + /// `split` owns the single normalization pass in the load path, so every + /// return — including the body-only one — hands back LF. + func testFrontmatterSplitWithoutDelimiterNormalizesCarriageReturns() throws { + let (frontmatter, body) = try Frontmatter.split("system:\r\nNo frontmatter here.\r\n") + + XCTAssertTrue(frontmatter.isEmpty) + XCTAssertEqual(body, "system:\nNo frontmatter here.\n") + } + + /// `\r\r\n` must keep its lone CR: exactly one normalization pass runs. + /// + /// The scalar scanner reads the CR + LF pair as the terminator and leaves the + /// preceding CR as content. A second pass over the already-normalized text + /// would read the residual `\r\n` as another terminator and delete that CR — + /// which is why `Loader` no longer normalizes before calling in. + func testAdjacentCarriageReturnSurvivesSingleNormalizationPass() throws { + let (_, body) = try Frontmatter.split("---\nname: cr\n---\nbefore\r\r\nafter") + XCTAssertEqual(body, "before\r\nafter") + + let bodyOnly = try Frontmatter.split("before\r\r\nafter") + XCTAssertEqual(bodyOnly.body, "before\r\nafter") + } + + /// An unterminated CRLF document must still be reported, not silently emptied. + func testFrontmatterSplitReportsUnclosedCarriageReturnDelimiter() { + XCTAssertThrowsError(try Frontmatter.split("---\r\nname: unclosed\r\nstill: yaml\r\n")) { + error in + guard case LoadError.invalidFrontmatter = error else { + return XCTFail("expected invalidFrontmatter, got \(error)") + } + } + } + + // MARK: - Files on disk + + /// The end-to-end Windows case: a `.prompty` file whose bytes contain CRLF. + /// + /// Every other line-ending test builds its input in memory, so none of them + /// exercises reading a file off disk and normalizing what came back. + func testLoadsPromptyFileWrittenWithCarriageReturnBytes() async throws { + let unix = """ + --- + name: crlf-file + model: + id: gpt-4o + --- + system: + You are helpful. + + user: + Hello there. + + """ + let path = try Self.writeTemporaryPrompt( + unix.replacingOccurrences(of: "\n", with: "\r\n")) + defer { try? FileManager.default.removeItem(at: path.deletingLastPathComponent()) } + + let agent = try Loader.load(path: path.path) + XCTAssertEqual(agent.name, "crlf-file") + XCTAssertEqual(agent.instructions, "system:\nYou are helpful.\n\nuser:\nHello there.") + + // The normalized instructions must still parse into distinct turns. + let messages = try await Pipeline.prepare(agent) + XCTAssertEqual(messages.map(\.role.rawValue), ["system", "user"]) + XCTAssertEqual(Self.text(messages[1].parts), "Hello there.") + } + + /// End-to-end: a lone CR inside a `\r\r\n` sequence survives the load path. + /// + /// This is the composition regression — `Loader` must not normalize before + /// `Frontmatter.split`, or the two passes together delete the CR. + func testLoadedInstructionsKeepLoneCarriageReturn() throws { + let raw = "---\r\nname: adjacent-cr\r\n---\r\nsystem:\r\nbefore\r\r\nafter\r\n" + let path = try Self.writeTemporaryPrompt(raw) + defer { try? FileManager.default.removeItem(at: path.deletingLastPathComponent()) } + + let agent = try Loader.load(path: path.path) + XCTAssertEqual(agent.instructions, "system:\nbefore\r\nafter") + } + + /// A CRLF file and its LF twin must load to the same prompt. + func testCarriageReturnFileMatchesUnixFile() throws { + let unix = """ + --- + name: twin + description: line endings must not matter + model: + id: gpt-4o + --- + system: + Identical. + + user: + Content. + + """ + let unixPath = try Self.writeTemporaryPrompt(unix) + let windowsPath = try Self.writeTemporaryPrompt( + unix.replacingOccurrences(of: "\n", with: "\r\n")) + defer { + try? FileManager.default.removeItem(at: unixPath.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: windowsPath.deletingLastPathComponent()) + } + + let expected = try Loader.load(path: unixPath.path) + let actual = try Loader.load(path: windowsPath.path) + + XCTAssertEqual(actual.name, expected.name) + XCTAssertEqual(actual.description, expected.description) + XCTAssertEqual(actual.instructions, expected.instructions) + } + + // MARK: - Server-sent events + + /// A `data:` line may still carry its CR when the transport splits on LF. + /// + /// `CharacterSet.whitespaces` is space and tab only, so a trailing CR survived + /// trimming and `[DONE]` never compared equal. + func testServerSentEventPayloadToleratesTrailingCarriageReturn() { + XCTAssertEqual(SSE.payload(of: "data: [DONE]\r"), "[DONE]") + XCTAssertEqual(SSE.payload(of: "data: {\"id\":\"a\"}\r"), "{\"id\":\"a\"}") + XCTAssertEqual(SSE.payload(of: "data: [DONE]"), "[DONE]") + XCTAssertNil(SSE.payload(of: "data:\r")) + XCTAssertNil(SSE.payload(of: ": keep-alive\r")) + } + + /// A CRLF-delimited SSE body must split into one record per event. + func testServerSentEventStreamSplitsCarriageReturnDelimitedBody() { + let body = "data: {\"n\":1}\r\n\r\ndata: {\"n\":2}\r\n\r\ndata: [DONE]\r\n" + let payloads = Lines.split(body).compactMap(SSE.payload(of:)) + XCTAssertEqual(payloads, ["{\"n\":1}", "{\"n\":2}", "[DONE]"]) + } + + private static func writeTemporaryPrompt(_ contents: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("prompty-line-endings-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let path = directory.appendingPathComponent("prompt.prompty") + // Write bytes directly: a String write would be re-encoded by the platform. + try Data(contents.utf8).write(to: path) + return path + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/LiveOpenAITests.swift b/runtime/swift/prompty/Tests/PromptyTests/LiveOpenAITests.swift new file mode 100644 index 000000000..5d3edaeee --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/LiveOpenAITests.swift @@ -0,0 +1,376 @@ +import Foundation + +import PromptyModel + +import PromptyOpenAI + +import XCTest + +/// End-to-end tests against the real OpenAI API. +/// +/// These are the only tests that leave the machine. They skip themselves when +/// `OPENAI_API_KEY` is absent, so a checkout without credentials still runs a +/// full green suite. Credentials come from a `.env` beside the package, which +/// is ignored by git and never committed. +/// +/// The runtime itself never reads `.env` — populating the environment is the +/// host's job, so the loading below belongs to the test, not the library. +@testable import Prompty + +// MARK: - .env loading + +/// Load `KEY=VALUE` lines from a `.env` beside the package into the process +/// environment, without overwriting values that are already set. +final class LiveOpenAITests: XCTestCase { + + private static let loaded: Bool = { + loadDotEnv() + Registry.shared.registerDefaults() + registerOpenAI() + return true + }() + + override func setUp() { + super.setUp() + _ = Self.loaded + } + + /// Skip rather than fail when the environment has no credentials. + private func requireCredentials() throws -> String { + let key = ProcessInfo.processInfo.environment["OPENAI_API_KEY"] ?? "" + try XCTSkipIf(key.isEmpty, "OPENAI_API_KEY not set — skipping live OpenAI tests") + return key + } + + private var modelId: String { + let configured = ProcessInfo.processInfo.environment["OPENAI_MODEL"] ?? "" + return configured.isEmpty ? "gpt-4o-mini" : configured + } + + // MARK: - Chat + + /// The whole pipeline against a real endpoint: load, render, parse, execute, + /// process. + func testLiveChatCompletion() async throws { + _ = try requireCredentials() + + let agent = try Prompty.load([ + "kind": "prompt", + "name": "live-chat", + "model": ["id": modelId, "provider": "openai", "apiType": "chat"], + "inputs": [["name": "topic", "kind": "string"]], + "instructions": + "system:\nAnswer with a single word and no punctuation.\n\nuser:\nWhat colour is a {{topic}}?", + ]) + + let result = try await Pipeline.invoke(agent, inputs: ["topic": "banana"]) + let text = try XCTUnwrap( + result as? String, "expected text content, got \(String(describing: result))") + + XCTAssertFalse(text.isEmpty) + XCTAssertTrue( + text.lowercased().contains("yellow"), + "expected the model to answer 'yellow', got \(text.debugDescription)") + } + + /// Model options must actually reach the provider. + /// + /// Asserted relatively: the same prompt is run twice, once capped and once + /// with room to answer. An absolute check on the capped reply alone would + /// also pass if the model simply happened to be terse. + func testLiveChatHonoursMaxOutputTokens() async throws { + _ = try requireCredentials() + + func answer(maxOutputTokens: Int) async throws -> String { + let agent = try Prompty.load([ + "kind": "prompt", + "name": "live-capped", + "model": [ + "id": modelId, "provider": "openai", "apiType": "chat", + "options": ["temperature": 0, "maxOutputTokens": maxOutputTokens], + ], + "instructions": "user:\nCount slowly from one to one hundred in words.", + ]) + let result = try await Pipeline.invoke(agent) + return try XCTUnwrap(result as? String) + } + + let capped = try await answer(maxOutputTokens: 16) + let generous = try await answer(maxOutputTokens: 800) + + XCTAssertFalse(capped.isEmpty) + XCTAssertFalse(generous.isEmpty) + XCTAssertGreaterThan( + generous.count, capped.count * 3, + "maxOutputTokens did not reach the provider — a 16-token cap and an " + + "800-token cap produced comparable output: " + + "capped=\(capped.count) generous=\(generous.count)") + } + + // MARK: - Streaming + + /// Streaming has to deliver the answer as a sequence of events, not one blob. + /// + /// This asserts on decoded SSE events rather than on wire timing. On Windows + /// `URLSession.bytes(for:)` is unavailable, so the provider buffers the + /// response and then decodes it; requiring many events still proves the SSE + /// framing and chunk accumulation are right, which is the part this runtime + /// owns. + func testLiveStreaming() async throws { + _ = try requireCredentials() + + let agent = try Prompty.load([ + "kind": "prompt", + "name": "live-stream", + "model": [ + "id": modelId, "provider": "openai", "apiType": "chat", + "options": ["temperature": 0, "additionalProperties": ["stream": true]], + ], + "instructions": + "user:\nList the numbers one through twenty in words, one per line, and nothing else.", + ]) + + let messages = try await Pipeline.prepare(agent) + let stream = try await Pipeline.stream(agent, messages: messages) + + var chunks = 0 + var text = "" + for try await chunk in stream { + if case .textChunk(let part) = chunk { + chunks += 1 + text += part.value + } + } + + // More than one event proves the body was decoded as a stream of SSE frames + // rather than handed over as a single blob. The floor is deliberately loose: + // a provider may legally coalesce deltas, so the real fidelity check is the + // ordered content assertion below. + XCTAssertGreaterThan(chunks, 1, "expected several streamed events, got \(chunks)") + + // Every item, in order, must survive reassembly. This is what a dropped or + // misordered chunk would break, and unlike comparing against a second + // request it does not assume the model is deterministic across calls. + let words = [ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", + "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", + "eighteen", "nineteen", "twenty", + ] + let lowered = text.lowercased() + var cursor = lowered.startIndex + for word in words { + guard let found = lowered.range(of: word, range: cursor.." + let input = vector["input"] as? [String: Any] ?? [:] + let expected = vector["expected"] as? [String: Any] ?? [:] + + run.check(name) { + try Self.runVector(name: name, input: input, expected: expected) + } + } + + run.assertClean() + } + + /// Pin `tools_function_load`'s bindings map: exactly one entry, key `unit`, + /// input `preferred_unit`. + /// + /// `validateBindings` alone cannot hold this. It is opt-in by key — it + /// returns early when a vector carries no `bindings` expectation — so + /// deleting that block from `load_vectors.json` leaves every load vector + /// green while the loader is free to drop bindings entirely. That is not + /// hypothetical: removing the block was measured, and the suite stayed at 0 + /// failures. This test asserts the expectation still *exists* and still says + /// what it is supposed to say, then checks the loaded tool against those + /// same literals, so neither half can quietly go missing. + /// + /// Bindings are read off `FunctionTool` rather than the `Tool.bindings` + /// convenience shim, so the generated type is what is under test. + func testFunctionToolBindingsArePinned() throws { + let vectors = try Spec.vectors("load") + let vector = try XCTUnwrap( + vectors.first { $0["name"] as? String == "tools_function_load" }, + "tools_function_load vector missing from load_vectors.json") + + // Half one: the vector still declares the expectation, in either of the two + // shapes validateBindings treats as equivalent. + let expected = try XCTUnwrap(vector["expected"] as? [String: Any]) + let expectedTools = try XCTUnwrap(expected["tools"] as? [[String: Any]]) + let declared = try XCTUnwrap( + expectedTools.first?["bindings"], + """ + tools_function_load lost its bindings expectation. validateBindings skips \ + absent keys, so removing it disables the check without failing anything. + """) + let declaredPairs = try Self.bindingPairs(declared) + XCTAssertEqual(declaredPairs.count, 1, "expected exactly one declared binding") + XCTAssertEqual( + declaredPairs["unit"], "preferred_unit", + "expected unit -> preferred_unit; vector declares \(declaredPairs)") + + // Half two: the loaded FunctionTool matches those literals exactly. + let agent = try Self.loadAgent(try XCTUnwrap(vector["input"] as? [String: Any])) + let tools = try XCTUnwrap(agent.tools, "fixture produced no tools") + XCTAssertEqual(tools.count, 1, "fixture declares one tool") + // Unwrap rather than subscript: XCTAssertEqual records but does not halt, so + // indexing here would trap instead of failing cleanly on an empty list. + let loaded = try XCTUnwrap(tools.first, "fixture produced no tools") + + guard case .functionTool(let function) = loaded else { + return XCTFail("expected a FunctionTool, got \(loaded.kindName)") + } + let bindings = try XCTUnwrap(function.bindings, "FunctionTool.bindings is nil") + + XCTAssertEqual(bindings.count, 1, "binding count") + XCTAssertEqual(bindings.map(\.name), ["unit"], "binding key") + XCTAssertEqual(bindings.map(\.input), ["preferred_unit"], "binding input") + } + + /// Normalize a vector's `bindings` expectation to `name -> input` pairs. + /// + /// `validateBindings` accepts a `Record` map and an already-named + /// list as equivalent. Pinning only the map form would report a re-emission + /// in the list form as a *missing* expectation, which is false and sends the + /// reader somewhere useless. + private static func bindingPairs(_ declared: Any) throws -> [String: String] { + func input(_ value: Any, for name: String) throws -> String { + guard let input = ((value as? [String: Any])?["input"] ?? value) as? String else { + throw VectorFailure("binding '\(name)' expectation has no string 'input'") + } + return input + } + + if let map = declared as? [String: Any] { + return try map.reduce(into: [:]) { pairs, entry in + pairs[entry.key] = try input(entry.value, for: entry.key) + } + } + + if let list = declared as? [[String: Any]] { + return try list.reduce(into: [:]) { pairs, entry in + guard let name = entry["name"] as? String else { + throw VectorFailure("bindings list entry has no string 'name': \(entry)") + } + // Reject a duplicate rather than letting the later entry overwrite the + // earlier one. A silent collapse shrinks the expectation set, so the + // vector would keep passing while checking fewer bindings than it + // declares — the shared duplicate-name failure mode, applied to the + // expectation side. + guard pairs[name] == nil else { + throw VectorFailure("bindings list declares '\(name)' more than once") + } + pairs[name] = try input(entry, for: name) + } + } + + throw VectorFailure("bindings expectation is neither a map nor a list: \(declared)") + } + + // MARK: - Dispatch + + private static func runVector( + name: String, input: [String: Any], expected: [String: Any] + ) throws { + let env = input["env"] as? [String: Any] ?? [:] + + return try withEnvironment(env) { + // Validation vectors drive validateInputs rather than plain loading. + if expected["validated_inputs"] != nil || expected["error_field"] != nil { + try runValidationVector(name: name, input: input, expected: expected) + return + } + + if let expectedError = expected["error"] as? String { + try runErrorVector(name: name, input: input, expectedError: expectedError) + return + } + + let agent = try loadAgent(input) + try validate(agent: agent, expected: expected) + } + } + + // MARK: - Loading + + /// Build a `Prompty` from whichever input form the vector uses. + static func loadAgent(_ input: [String: Any]) throws -> Prompty { + if let fixture = input["fixture"] as? String { + return try Loader.load(path: Spec.fixtures.appendingPathComponent(fixture).path) + } + + if let files = input["files"] as? [String: Any] { + let directory = try makeTempDirectory("file_res") + defer { try? FileManager.default.removeItem(at: directory) } + + for (relative, content) in files { + let target = directory.appendingPathComponent(relative) + try FileManager.default.createDirectory( + at: target.deletingLastPathComponent(), withIntermediateDirectories: true) + let text: String + if let string = content as? String { + text = string + } else { + let data = try JSONSerialization.data(withJSONObject: content, options: [.prettyPrinted]) + text = String(data: data, encoding: .utf8) ?? "" + } + try text.write(to: target, atomically: true, encoding: .utf8) + } + + let raw = try promptyDocument(frontmatter: input["frontmatter"]) + return try Loader.load( + contents: raw, basePath: directory.appendingPathComponent("virtual.prompty").path) + } + + if let raw = input["frontmatter_raw"] as? String { + return try Loader.load(contents: raw, basePath: workingFile) + } + + guard input["frontmatter"] != nil else { + throw VectorFailure("vector has no fixture, frontmatter, or frontmatter_raw") + } + let raw = try promptyDocument(frontmatter: input["frontmatter"]) + return try Loader.load(contents: raw, basePath: workingFile) + } + + /// Serialize a vector's `frontmatter` object into a `.prompty` document. + /// + /// The vectors are JSON and YAML 1.2 is a JSON superset, so emitting the + /// JSON directly as a flow mapping is both simpler and lossless — no + /// number/boolean representation round-trip through a YAML emitter. + private static func promptyDocument(frontmatter: Any?) throws -> String { + guard let mapping = frontmatter as? [String: Any], !mapping.isEmpty else { + return "---\n---\n" + } + let data = try JSONSerialization.data(withJSONObject: mapping, options: [.sortedKeys]) + guard let json = String(data: data, encoding: .utf8) else { + throw VectorFailure("frontmatter is not encodable") + } + return "---\n\(json)\n---\n" + } + + private static var workingFile: String { + FileManager.default.currentDirectoryPath + "/virtual.prompty" + } + + static func makeTempDirectory(_ suffix: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("prompty_spec_\(suffix)_\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + // MARK: - Error vectors + + private static func runErrorVector( + name: String, input: [String: Any], expectedError: String + ) throws { + do { + let agent = try loadAgent(input) + + // The generated Template.load accepts a bare string and yields empty + // format/parser kinds instead of raising. Rust behaves the same way, so + // the vector is satisfied by proving the template is unusable. + if name == "template_string_invalid" { + let format = agent.template?.format.kind ?? "" + let parser = agent.template?.parser.kind ?? "" + try expect( + format.isEmpty && parser.isEmpty, + "expected an unusable template, got format='\(format)' parser='\(parser)'") + return + } + + throw VectorFailure("expected error containing '\(expectedError)', but load succeeded") + } catch let failure as VectorFailure { + throw failure + } catch { + try expectErrorMatches(error, expectedError) + } + } + + /// Vectors describe errors loosely (they are shared across runtimes whose + /// message wording differs), so match on significant words. + static func expectErrorMatches(_ error: Error, _ expected: String) throws { + let actual = String(describing: error).lowercased() + let wanted = expected.lowercased() + + if actual.contains(wanted) { return } + + // Vectors name errors using the Python runtime's exception class names. + if wanted.contains("filenotfound"), actual.contains("not found") { return } + if wanted.contains("valueerror") || wanted.contains("keyerror") { return } + + let significant = wanted.split(whereSeparator: { !$0.isLetter }).filter { $0.count > 3 } + if !significant.isEmpty, significant.contains(where: { actual.contains($0) }) { return } + + throw VectorFailure("error mismatch:\n expected: '\(expected)'\n actual: '\(error)'") + } + + // MARK: - Validation vectors + + private static func runValidationVector( + name: String, input: [String: Any], expected: [String: Any] + ) throws { + let agent = try loadAgent(input) + let inputs = input["inputs"] as? [String: Any] ?? [:] + + if let expectedInputs = expected["validated_inputs"] as? [String: Any] { + let validated = try Pipeline.validateInputs(agent, inputs: inputs) + for (key, value) in expectedInputs { + try expectEqual(validated[key], value, "validated_inputs.\(key)") + } + // Keys the vector omits must not have been invented. + for key in validated.keys where expectedInputs[key] == nil { + throw VectorFailure("validated_inputs has unexpected key '\(key)'") + } + return + } + + do { + _ = try Pipeline.validateInputs(agent, inputs: inputs) + throw VectorFailure("expected validation to fail") + } catch let failure as VectorFailure { + throw failure + } catch { + if let expectedError = expected["error"] as? String { + try expectErrorMatches(error, expectedError) + } + if let field = expected["error_field"] as? String { + try expect( + String(describing: error).contains(field), + "expected error to name field '\(field)', got '\(error)'") + } + } + } + + // MARK: - Assertions + + private static func validate(agent: Prompty, expected: [String: Any]) throws { + if let kind = expected["kind"] as? String { + try expect(kind == "prompt", "vector expected kind=prompt, declared '\(kind)'") + } + if let name = expected["name"] as? String { + try expectEqual(agent.name, name, "name") + } + if let description = expected["description"] as? String { + try expectEqual(agent.description, description, "description") + } + if let instructions = expected["instructions"] as? String { + try expectEqual(agent.instructions, instructions, "instructions") + } + + if let model = expected["model"] { + if model is NSNull { + try expect(agent.model.id.isEmpty, "expected no model, got id='\(agent.model.id)'") + } else if let model = model as? [String: Any] { + try validateModel(agent.model, expected: model) + } + } + + if let inputs = expected["inputs"] { + try validateProperties(agent.inputs, expected: inputs, label: "inputs") + } + if let outputs = expected["outputs"] { + try validateProperties(agent.outputs, expected: outputs, label: "outputs") + } + + if let tools = expected["tools"] { + if tools is NSNull { + try expect( + (agent.tools ?? []).isEmpty, "expected no tools, got \((agent.tools ?? []).count)") + } else if let expectedTools = tools as? [[String: Any]] { + let actual = agent.tools ?? [] + try expect( + actual.count == expectedTools.count, + "tools count: expected \(expectedTools.count), got \(actual.count)") + for (index, expectedTool) in expectedTools.enumerated() { + try validateTool(actual[index], expected: expectedTool, index: index) + } + } + } + + if let template = expected["template"] as? [String: Any] { + if let format = template["format"] as? [String: Any], let kind = format["kind"] as? String { + try expectEqual(agent.template?.format.kind, kind, "template.format.kind") + } + if let parser = template["parser"] as? [String: Any], let kind = parser["kind"] as? String { + try expectEqual(agent.template?.parser.kind, kind, "template.parser.kind") + } + } + + if let metadata = expected["metadata"] as? [String: Any] { + for (key, value) in metadata { + try expectEqual(agent.metadata?[key], value, "metadata.\(key)") + } + } + } + + private static func validateModel(_ model: Model, expected: [String: Any]) throws { + if let id = expected["id"] as? String { + try expectEqual(model.id, id, "model.id") + } + if let provider = expected["provider"] as? String { + try expectEqual(model.provider, provider, "model.provider") + } + if let apiType = expected["apiType"] as? String { + try expectEqual(model.apiType?.rawValue, apiType, "model.apiType") + } + + if let connection = expected["connection"] as? [String: Any] { + let actual = (try? model.connection?.save()) ?? [:] + for (key, value) in connection { + try expectEqual(actual[key], value, "model.connection.\(key)") + } + } + + if let options = expected["options"] as? [String: Any] { + let actual = (try? model.options?.save()) ?? [:] + for (key, value) in options { + try expectEqual(actual[key], value, "model.options.\(key)") + } + } + } + + private static func validateProperties( + _ actual: [Property]?, expected: Any, label: String + ) throws { + if expected is NSNull { + try expect((actual ?? []).isEmpty, "expected no \(label), got \((actual ?? []).count)") + return + } + guard let expectedList = expected as? [[String: Any]] else { return } + let properties = actual ?? [] + try expect( + properties.count == expectedList.count, + "\(label) count: expected \(expectedList.count), got \(properties.count)") + + for (index, expectedProperty) in expectedList.enumerated() { + let property = properties[index] + if let name = expectedProperty["name"] as? String { + try expectEqual(property.name, name, "\(label)[\(index)].name") + } + if let kind = expectedProperty["kind"] as? String { + try expectEqual(property.kindName, kind, "\(label)[\(index)].kind") + } + if let value = expectedProperty["default"] { + try expectEqual(property.defaultValue, value, "\(label)[\(index)].default") + } + } + } + + private static func validateTool(_ tool: Tool, expected: [String: Any], index: Int) throws { + let raw = tool.raw + + if let name = expected["name"] as? String { + try expectEqual(tool.name, name, "tools[\(index)].name") + } + if let kind = expected["kind"] as? String { + try expectEqual(tool.kindName, kind, "tools[\(index)].kind") + } + if let description = expected["description"] as? String { + try expectEqual(tool.toolDescription, description, "tools[\(index)].description") + } + if let strict = expected["strict"] as? Bool { + try expectEqual(raw["strict"], strict, "tools[\(index)].strict") + } + if let serverName = expected["serverName"] as? String { + try expectEqual(raw["serverName"], serverName, "tools[\(index)].serverName") + } + if let specification = expected["specification"] as? String { + try expectEqual(raw["specification"], specification, "tools[\(index)].specification") + } + if let path = expected["path"] as? String { + try expectEqual(raw["path"], path, "tools[\(index)].path") + } + if let mode = expected["mode"] as? String { + try expectEqual(raw["mode"], mode, "tools[\(index)].mode") + } + if let parameters = expected["parameters"] as? [[String: Any]] { + let actual = tool.functionParameters + try expect( + actual.count == parameters.count, + "tools[\(index)].parameters count: expected \(parameters.count), got \(actual.count)") + for (position, expectedParameter) in parameters.enumerated() { + if let name = expectedParameter["name"] as? String { + try expectEqual( + actual[position].name, name, "tools[\(index)].parameters[\(position)].name") + } + } + } + try validateBindings(tool, expected: expected, index: index) + } + + /// Bindings are declared either as a `Record` map — where the key + /// supplies the binding name — or as an already-named list. Both normalize to + /// the same loaded shape, so both expectation forms are checked here against + /// binding *identity* rather than the emitter's chosen representation. + /// + /// Without this the vectors' `bindings` expectations are inert: every other + /// field is opt-in by key, so an unchecked key silently passes no matter what + /// the loader produced. + /// + /// Name addressing is sound only while the expected names are unique and + /// non-empty. Object form cannot carry one key twice, and an empty key + /// disqualifies it as well, so either one proves the source used the array + /// fallback — the only ordered representation — and those entries are + /// compared positionally instead. Entries that qualify for object form stay + /// order- and representation-agnostic, because both forms are legal for them. + /// + /// Not `private`: `BindingExpectationPairingTests` drives this directly, since + /// no current fixture declares a duplicate binding name. + static func validateBindings( + _ tool: Tool, expected: [String: Any], index: Int + ) throws { + guard let declared = expected["bindings"] else { return } + let label = "tools[\(index)].bindings" + let actual = tool.bindings + + /// Read the expected input from either `{input: ...}` or a bare input name. + func expectedInput(_ spec: Any, _ entryLabel: String) throws -> String { + guard let input = ((spec as? [String: Any])?["input"] ?? spec) as? String else { + throw VectorFailure("\(entryLabel) expectation has no string 'input'") + } + return input + } + + /// Compare one expected binding, addressed by name. Sound only where the + /// expected names are unique; the list branch handles the duplicate case. + func check(name: String, spec: Any) throws { + guard let binding = actual.first(where: { $0.name == name }) else { + throw VectorFailure( + "\(label) missing '\(name)'; got \(actual.map(\.name).sorted())") + } + let input = try expectedInput(spec, "\(label)[\(name)]") + try expectEqual(binding.input, input, "\(label)[\(name)].input") + } + + // Object keys are unique by construction, and every declared key must be + // found against a matching count, so a duplicate in `actual` cannot hide + // here: it either breaks the count or leaves an expected key missing. + if let map = declared as? [String: Any] { + // An empty name disqualifies object form, so a map expectation carrying + // one is malformed rather than something to address by name. + guard !map.keys.contains("") else { + throw VectorFailure("\(label) map expectation has an empty key; that needs list form") + } + try expect( + actual.count == map.count, + "\(label) count: expected \(map.count), got \(actual.count)") + for (name, spec) in map { + try check(name: name, spec: spec) + } + return + } + + if let list = declared as? [[String: Any]] { + try expect( + actual.count == list.count, + "\(label) count: expected \(list.count), got \(actual.count)") + + let names = try list.map { entry -> String in + guard let name = entry["name"] as? String else { + throw VectorFailure("\(label) list entry has no string 'name': \(entry)") + } + return name + } + + // Pre-scan before any name-addressed lookup. Object form cannot carry the + // same key twice, and an empty key disqualifies it too, so either one + // proves the source was the array fallback, which carries order — these + // entries are positional. Matching them by name would bind expectations + // to the same entry twice, or to an entry that merely shares a name, + // leaving the rest unverified while still reporting a pass. + let nameAddressable = Set(names).count == names.count && !names.contains("") + guard nameAddressable else { + for (position, entry) in list.enumerated() { + let entryLabel = "\(label)[\(position)]" + try expectEqual(actual[position].name, names[position], "\(entryLabel).name") + let input = try expectedInput(entry, entryLabel) + try expectEqual(actual[position].input, input, "\(entryLabel).input") + } + return + } + + for (position, entry) in list.enumerated() { + try check(name: names[position], spec: entry) + } + return + } + + // Fail closed: an unrecognized shape must not be silently unchecked, which + // is exactly how this expectation went unverified in the first place. + throw VectorFailure("\(label) expectation is neither a map nor a list: \(declared)") + } +} +func withEnvironment(_ values: [String: Any], _ body: () throws -> T) rethrows -> T { + var restore: [String: String?] = [:] + for (key, value) in values { + guard let string = value as? String else { continue } + restore[key] = ProcessInfo.processInfo.environment[key] + Env.set(key, string) + } + defer { + for (key, previous) in restore { + Env.set(key, previous) + } + } + return try body() +} +enum Env { + static func set(_ key: String, _ value: String?) { + #if os(Windows) + _ = key.withCString(encodedAs: UTF16.self) { name in + (value ?? "").withCString(encodedAs: UTF16.self) { item in + _wputenv_s(name, item) + } + } + #else + if let value { + setenv(key, value, 1) + } else { + unsetenv(key) + } + #endif + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/NamedCollectionSaveFormTests.swift b/runtime/swift/prompty/Tests/PromptyTests/NamedCollectionSaveFormTests.swift new file mode 100644 index 000000000..041218428 --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/NamedCollectionSaveFormTests.swift @@ -0,0 +1,360 @@ +import XCTest + +@testable import Prompty +@testable import PromptyModel + +/// Save-form selection for collections of named entities (spec §2.9.2). +/// +/// Named collections may be saved either as a name-keyed object or as a whole +/// ordered array. The canonical rule restricts the object form to **non-empty, +/// exactly-unique** names and requires the ordered-array fallback otherwise. +/// +/// That precondition is not cosmetic. A JSON object cannot hold two identical +/// keys, so applying the object form to a collection with duplicate names +/// destroys entries *by construction* — silently, with no error to notice. A +/// candidate emitter was withdrawn from cross-runtime acceptance for exactly +/// this defect in another language backend: duplicate-named entries collapsed +/// on save. +/// +/// These tests split into two groups, and the distinction matters: +/// +/// - **Disqualifying fixtures** (duplicate names, empty name). The array +/// fallback is *mandatory*, so these assert the saved form directly as well +/// as full payload survival. Asserting the form here cannot reject legal +/// behaviour, because no legal emitter may choose the object form for these +/// inputs. Note in particular that JSON permits `""` as an object key, so an +/// emitter could illegally object-encode the empty-name fixture and still +/// round-trip both entries — payload assertions alone would not catch it. +/// Both disqualifiers are proven on `tools` *and* on `inputs`, which save +/// through different paths and can therefore fail independently. +/// +/// Payloads are compared as composite `name|value` entries, never as two +/// independent projections: comparing names and values separately proves +/// both multisets survived but not which value belongs to which name. Where +/// a fixture repeats a name, the comparison is additionally positional, +/// since exchanging the payloads of two identically-named entries leaves any +/// sorted multiset unchanged and is observable only in order. +/// +/// - **Qualifying fixture** (unique names). Both forms are legal, so that test +/// asserts survival and payload only, never the form and never reload order. +/// +/// At the currently pinned emitter the array form is selected unconditionally, +/// including for unique names, so the qualifying test currently exercises only +/// the array path despite its "whichever form" name. A candidate emitter was +/// measured selecting the object form for unique names while correctly falling +/// back to the array form for duplicates and empty names; all of these tests +/// were validated green against it. +/// +/// Order is asserted only where canonical actually promises it — in the array +/// fallback. The object form makes no ordering promise, so nothing outside that +/// one test depends on one. See `ConnectionRoundTripTests` for why an unrelated +/// test must not smuggle in an ordering assumption. +final class NamedCollectionSaveFormTests: XCTestCase { + + // MARK: - Helpers + + /// `name|description` per tool, in collection order. + /// + /// Deliberately a *composite* signature rather than two independent + /// projections. Comparing sorted names and sorted descriptions separately + /// proves both multisets survived but not their association, so an + /// implementation that reattached the wrong payload to a name would pass. + private func toolSignatures(_ agent: Prompty) -> [String] { + (agent.tools ?? []).map { tool in + switch tool { + case .functionTool(let value): return "\(value.name)|\(value.description ?? "")" + case .customTool(let value): return "\(value.name)|\(value.description ?? "")" + default: return "" + } + } + } + + /// `name|default` per saved property entry, in saved order. + /// + /// Read from the saved dictionary rather than the model because a scalar + /// `Property` resolves to the `.unknown` passthrough case, so switching on + /// the generated enum would end up reading this same dictionary anyway. + private func propertySignatures(of entries: [[String: Any]]) -> [String] { + entries.map { entry in + let name = entry["name"] as? String ?? "" + let value = entry["default"].map { String(describing: $0) } ?? "" + return "\(name)|\(value)" + } + } + + /// Describes the chosen save form, for diagnostics only. + private func saveForm(_ saved: [String: Any], _ key: String) -> String { + if saved[key] is [Any] { return "ordered array" } + if let object = saved[key] as? [String: Any] { + return "name-keyed object with keys \(object.keys.sorted())" + } + return "neither array nor object: \(String(describing: saved[key]))" + } + + /// Asserts the ordered-array fallback was chosen and returns its entries. + /// + /// Only call this for fixtures where the array form is *mandatory* under + /// §2.9.2. Returns `nil` after failing, so callers `guard` rather than + /// indexing into a collection that may not exist. + private func requireArrayForm( + _ saved: [String: Any], + _ key: String, + because reason: String, + file: StaticString = #filePath, + line: UInt = #line + ) -> [[String: Any]]? { + guard let entries = saved[key] as? [[String: Any]] else { + XCTFail( + "\(key) was not saved as an ordered array, but the array fallback is mandatory here " + + "because \(reason). Saved as \(saveForm(saved, key)).", + file: file, line: line) + return nil + } + return entries + } + + private func agent(tools: [[String: Any]]) throws -> Prompty { + try Prompty.load([ + "kind": "prompt", + "name": "save-form", + "model": ["id": "gpt-4o-mini", "apiType": "chat"], + "tools": tools, + "instructions": "user:\nhi", + ]) + } + + // MARK: - Disqualifying fixtures: the array fallback is mandatory + + /// Two tools sharing a name both survive a save/reload, and the array + /// fallback is chosen. + /// + /// This is the precise defect that withdrew a candidate emitter in another + /// runtime. The third, uniquely-named tool is present so the collection is + /// genuinely mixed — a naive implementation that falls back only when *every* + /// name repeats would still pass a duplicates-only fixture. The two duplicates + /// carry different descriptions so that a collapse-then-clone implementation, + /// which restores the count but not the content, still fails. + func testDuplicateToolNamesSurviveSaveAndReload() throws { + let loaded = try agent(tools: [ + ["name": "dup", "kind": "function", "description": "first"], + ["name": "dup", "kind": "function", "description": "second"], + ["name": "unique", "kind": "function", "description": "third"], + ]) + + XCTAssertEqual(loaded.tools?.count, 3, "the fixture did not load three tools to begin with") + + let saved = try loaded.save() + guard requireArrayForm(saved, "tools", because: "two tools share the name 'dup'") != nil else { + return + } + + let reloaded = try Prompty.load(saved) + XCTAssertEqual( + reloaded.tools?.count, 3, + "duplicate-named tools collapsed on save/reload — saved as \(saveForm(saved, "tools"))") + XCTAssertEqual( + toolSignatures(reloaded).sorted(), ["dup|first", "dup|second", "unique|third"], + "a duplicate-named tool lost or swapped its payload — " + + "saved as \(saveForm(saved, "tools"))") + } + + /// The same guarantee for properties, which are a separate collection with + /// its own save path. + /// + /// Asserts `name|default` signatures rather than a bare count, so a + /// collapse-then-clone implementation that restores the count with the wrong + /// content still fails. + func testDuplicatePropertyNamesSurviveSaveAndReload() throws { + let loaded = try Prompty.load([ + "kind": "prompt", + "name": "dup-props", + "model": ["id": "gpt-4o-mini", "apiType": "chat"], + "inputs": [ + ["name": "dup", "kind": "string", "default": "a"], + ["name": "dup", "kind": "string", "default": "b"], + ["name": "unique", "kind": "string", "default": "c"], + ], + "instructions": "user:\nhi", + ]) + + XCTAssertEqual(loaded.inputs?.count, 3, "the fixture did not load three inputs to begin with") + + let saved = try loaded.save() + guard + let savedEntries = requireArrayForm( + saved, "inputs", because: "two inputs share the name 'dup'") + else { return } + + // Assert the *first* serialized output, not only the re-save below. A + // defect that reverses entries would reverse them once, reload in that + // order, then reverse them back on the second save — cancelling out and + // leaving a re-save-only assertion green while the first output violated + // the canonical order. Any involutive corruption hides the same way. + XCTAssertEqual( + propertySignatures(of: savedEntries), ["dup|a", "dup|b", "unique|c"], + "the first save lost, exchanged or reordered a duplicate-named input's default") + + let reloaded = try Prompty.load(saved) + XCTAssertEqual( + reloaded.inputs?.count, 3, + "duplicate-named inputs collapsed on save/reload — saved as \(saveForm(saved, "inputs"))") + + // Re-save so payload can be compared without switching on the generated + // Property enum, whose scalar case is the `.unknown` dictionary passthrough. + // This second check is retained because it catches reload-side corruption + // that the first-save assertion cannot see. + let resaved = try reloaded.save() + guard + let entries = requireArrayForm(resaved, "inputs", because: "two inputs share the name 'dup'") + else { return } + + // Declaration order, not a sorted multiset. Both duplicates are named + // `dup`, so exchanging their defaults leaves the sorted multiset identical + // and would pass — the association is only observable positionally. Legal + // to assert here because the array form was required above, and the array + // fallback is the one representation canonical promises an order for. + XCTAssertEqual( + propertySignatures(of: entries), ["dup|a", "dup|b", "unique|c"], + "a duplicate-named input lost, exchanged or reordered its default across reload") + } + + /// An empty name is the other disqualifier, and it needs the form assertion + /// more than the duplicate cases do: JSON permits `""` as an object key, so + /// an illegal object encoding would round-trip both entries cleanly and pass + /// every payload assertion. Only checking the form catches it. + /// + /// Order is asserted here because the mandatory array form promises it. + func testEmptyNameForcesTheArrayFallbackAndDropsNothing() throws { + let loaded = try agent(tools: [ + ["name": "", "kind": "function", "description": "blank"], + ["name": "named", "kind": "function", "description": "ok"], + ]) + + XCTAssertEqual(loaded.tools?.count, 2, "the fixture did not load two tools to begin with") + + let saved = try loaded.save() + guard requireArrayForm(saved, "tools", because: "one tool has an empty name") != nil else { + return + } + + let reloaded = try Prompty.load(saved) + XCTAssertEqual( + reloaded.tools?.count, 2, + "an empty-named tool was dropped on save/reload — saved as \(saveForm(saved, "tools"))") + XCTAssertEqual( + toolSignatures(reloaded), ["|blank", "named|ok"], + "the empty-named tool lost its name or payload, or the entries were reordered") + } + + /// The empty-name disqualifier on the `inputs` path. + /// + /// `inputs` saves through a different path than `tools`, so each disqualifier + /// has to be proven on both collections independently — an implementation can + /// get one right and the other wrong. The duplicate-name case already is; + /// this closes the empty-name case. + /// + /// It is also the disqualifier least likely to be caught by accident. `""` is + /// a legal JSON object key, so an illegal object encoding round-trips both + /// entries cleanly and satisfies every payload assertion. Only the form check + /// rejects it. + func testEmptyPropertyNameForcesTheArrayFallback() throws { + let loaded = try Prompty.load([ + "kind": "prompt", + "name": "empty-name-prop", + "model": ["id": "gpt-4o-mini", "apiType": "chat"], + "inputs": [ + ["name": "", "kind": "string", "default": "blank"], + ["name": "named", "kind": "string", "default": "ok"], + ], + "instructions": "user:\nhi", + ]) + + XCTAssertEqual(loaded.inputs?.count, 2, "the fixture did not load two inputs to begin with") + + let saved = try loaded.save() + guard + let savedEntries = requireArrayForm( + saved, "inputs", because: "one input has an empty name") + else { return } + + // The first serialized output, for the same cancellation reason as the + // duplicate-name test: an involutive reordering defect would undo itself + // across the save/reload/save cycle below. + XCTAssertEqual( + propertySignatures(of: savedEntries), ["|blank", "named|ok"], + "the first save lost, exchanged or reordered the empty-named input") + + let reloaded = try Prompty.load(saved) + XCTAssertEqual( + reloaded.inputs?.count, 2, + "an empty-named input was dropped on save/reload — saved as \(saveForm(saved, "inputs"))") + + // Re-save for the same reason as the duplicate-name property test: a scalar + // `Property` resolves to the `.unknown` passthrough, so the saved + // dictionary is where the payload is legible. Retained alongside the + // first-save assertion because it catches reload-side corruption. + let resaved = try reloaded.save() + guard + let entries = requireArrayForm(resaved, "inputs", because: "one input has an empty name") + else { return } + + XCTAssertEqual( + propertySignatures(of: entries), ["|blank", "named|ok"], + "the empty-named input lost its name or default, or the entries were reordered") + } + + /// Where the array fallback is mandatory, order is part of the contract. + /// + /// This fixture has duplicate names, so a conforming emitter *must* choose the + /// array form. Skipping when it does not would let a defective object-form + /// selection leave CI green, so the form is asserted rather than tolerated. + func testArrayFallbackPreservesDeclarationOrder() throws { + let loaded = try agent(tools: [ + ["name": "dup", "kind": "function", "description": "first"], + ["name": "dup", "kind": "function", "description": "second"], + ["name": "aaa_last_alphabetically_first", "kind": "function", "description": "third"], + ]) + + let saved = try loaded.save() + guard requireArrayForm(saved, "tools", because: "two tools share the name 'dup'") != nil else { + return + } + + let reloaded = try Prompty.load(saved) + + // Declaration order, not alphabetical order — the third tool sorts first by + // name, so an accidental re-sort would move it and fail here. + XCTAssertEqual( + toolSignatures(reloaded), + ["dup|first", "dup|second", "aaa_last_alphabetically_first|third"], + "the ordered-array fallback did not preserve declaration order") + } + + // MARK: - Qualifying fixture: either form is legal + + /// Uniquely-named entries survive whichever form is chosen. + /// + /// Both forms are legal here, so this asserts survival and payload only. It + /// deliberately does **not** assert the saved form, and **not** reload order: + /// the object form makes no ordering promise, so pinning either would fail on + /// a legal emitter change for a reason unrelated to what this checks. The + /// names are chosen so declaration order and alphabetical order disagree, + /// which is what makes an accidental order assertion visible in review. + func testUniquelyNamedToolsSurviveWhicheverFormIsUsed() throws { + let loaded = try agent(tools: [ + ["name": "b_tool", "kind": "function", "description": "bee"], + ["name": "a_tool", "kind": "function", "description": "ay"], + ]) + + let saved = try loaded.save() + let reloaded = try Prompty.load(saved) + + XCTAssertEqual( + reloaded.tools?.count, 2, + "a uniquely-named tool was dropped — saved as \(saveForm(saved, "tools"))") + XCTAssertEqual( + toolSignatures(reloaded).sorted(), ["a_tool|ay", "b_tool|bee"], + "a uniquely-named tool lost or swapped its payload — " + + "saved as \(saveForm(saved, "tools"))") + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/NamedCollectionShorthandTests.swift b/runtime/swift/prompty/Tests/PromptyTests/NamedCollectionShorthandTests.swift new file mode 100644 index 000000000..0b322473f --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/NamedCollectionShorthandTests.swift @@ -0,0 +1,237 @@ +import Foundation + +import PromptyModel + +import XCTest + +/// Pins the named-collection shorthand rules directly, without the shared +/// fixture. +/// +/// `NamedCollectionVectorTests` drives the canonical +/// `spec/vectors/model/named_collection_vectors.json`, but that file arrives +/// with PR #447 and skips until then. These tests assert the same rules from +/// this branch so the behaviour is covered now, and so a regression is reported +/// against a named rule rather than only as a vector diff. +/// +/// The rules, all for a *name-keyed* `inputs` / `outputs` / nested `properties` +/// map: +/// +/// 1. The key supplies `name`. +/// 2. A bare scalar infers `kind` and is stored in `default`; `example` stays +/// absent. +/// 3. An immediate array value is rejected with the full dotted path. +/// 4. Arrays inside declared property fields (`default`, `items`) stay valid. +/// 5. The *list* form is unchanged: a bare scalar there is direct `@coerce` +/// input and keeps landing in `example`. +/// +/// Rules 2 and 5 are the pair that matters most. They look alike and differ in +/// the field they populate, so they are asserted against each other rather than +/// in isolation. +@testable import Prompty + +final class NamedCollectionShorthandTests: XCTestCase { + + // MARK: - Helpers + + private func loadInputs(_ frontmatter: String) throws -> [[String: Any]] { + let contents = "---\n\(frontmatter)\n---\nsystem:\nvector\n" + let agent = try Loader.load( + contents: contents, basePath: FileManager.default.currentDirectoryPath) + return try (agent.inputs ?? []).map { try $0.save() } + } + + private func expectRejection( + _ frontmatter: String, path expectedPath: String, category expectedCategory: String = "array", + file: StaticString = #filePath, line: UInt = #line + ) { + let contents = "---\n\(frontmatter)\n---\nsystem:\nvector\n" + do { + _ = try Loader.load(contents: contents, basePath: FileManager.default.currentDirectoryPath) + XCTFail( + "expected rejection at \(expectedPath), but the load succeeded", + file: file, line: line) + } catch let error as LoadError { + guard case .invalidNamedCollectionEntry(let path, let category) = error else { + XCTFail( + "expected LoadError.invalidNamedCollectionEntry, got \(error). A generic " + + "rejection carries neither the path nor the value category.", + file: file, line: line) + return + } + XCTAssertEqual(path, expectedPath, "rejected path", file: file, line: line) + XCTAssertEqual(category, expectedCategory, "value category", file: file, line: line) + XCTAssertTrue( + String(describing: error).contains("invalid-named-collection-entry"), + "the diagnostic must carry the machine-readable token: \(error)", + file: file, line: line) + } catch { + XCTFail("expected a LoadError, got \(error)", file: file, line: line) + } + } + + // MARK: - Rule 2: scalar shorthand stores `default` + + func testMapFormScalarStoresDefaultAndOmitsExample() throws { + let cases: [(literal: String, kind: String, expected: Any)] = [ + ("Seattle", "string", "Seattle"), + ("3", "integer", 3), + ("1.5", "float", 1.5), + ("true", "boolean", true), + ] + + for probe in cases { + let entries = try loadInputs("name: t\ninputs:\n city: \(probe.literal)") + XCTAssertEqual(entries.count, 1, "\(probe.literal): entry count") + guard let entry = entries.first else { continue } + + XCTAssertEqual(entry["name"] as? String, "city", "\(probe.literal): name from key") + XCTAssertEqual(entry["kind"] as? String, probe.kind, "\(probe.literal): inferred kind") + + // JSON text rather than `==`: Foundation bridges 0/1 to Bool, so a + // direct comparison would let `0` satisfy `false`. + XCTAssertEqual( + try jsonText(entry["default"] as Any), try jsonText(probe.expected), + "\(probe.literal): scalar must land in `default`") + + XCTAssertNil( + entry["example"], + "\(probe.literal): `example` must stay absent — populating it too would " + + "blur the named-collection shorthand with the direct @coerce contract") + } + } + + func testMapFormScalarShorthandAppliesToOutputs() throws { + let contents = "---\nname: t\noutputs:\n answer: hello\n---\nsystem:\nvector\n" + let agent = try Loader.load( + contents: contents, basePath: FileManager.default.currentDirectoryPath) + let entries = try (agent.outputs ?? []).map { try $0.save() } + + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries.first?["name"] as? String, "answer") + XCTAssertEqual(entries.first?["kind"] as? String, "string") + XCTAssertEqual(entries.first?["default"] as? String, "hello") + XCTAssertNil(entries.first?["example"]) + } + + // MARK: - Rule 5: the list form keeps direct @coerce semantics + + func testListFormBareScalarStillUsesExample() throws { + // The two shorthands are distinguished by position, not by value. A list + // element is a Property in its own right, so it follows @coerce. + let entries = try loadInputs("name: t\ninputs:\n - Seattle") + + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries.first?["kind"] as? String, "string") + XCTAssertEqual( + entries.first?["example"] as? String, "Seattle", + "a bare scalar in list position is direct @coerce input and keeps `example`") + XCTAssertNil( + entries.first?["default"], + "the direct form must not also populate `default`, or the two contracts " + + "become indistinguishable") + } + + // MARK: - Rule 3: immediate arrays are rejected, with a path + + func testScalarArrayInNameKeyedInputsIsRejected() { + expectRejection( + "name: t\ninputs:\n arrayDefault: [1, two, null]", path: "inputs.arrayDefault") + } + + func testObjectArrayInNameKeyedInputsIsRejected() { + expectRejection( + "name: t\ninputs:\n arrayEntry:\n - kind: string", path: "inputs.arrayEntry") + } + + func testArrayInNestedPropertiesIsRejectedWithFullPath() { + // Recursion must thread the path, not restart it — a bare `arrayEntry` + // would not tell a consumer where the defect is. + let frontmatter = [ + "name: t", + "inputs:", + " profile:", + " kind: object", + " properties:", + " arrayEntry:", + " - kind: string", + ].joined(separator: "\n") + expectRejection(frontmatter, path: "inputs.profile.properties.arrayEntry") + } + + func testArrayInNameKeyedOutputsIsRejected() { + expectRejection("name: t\noutputs:\n bad: [1]", path: "outputs.bad") + } + + // MARK: - Rule 4: arrays inside declared fields remain valid + + func testDeclaredArrayFieldsRemainValid() throws { + let entries = try loadInputs( + [ + "name: t", + "inputs:", + " aliases:", + " kind: array", + " default: [Ada, Grace]", + " items:", + " kind: string", + ].joined(separator: "\n")) + + XCTAssertEqual(entries.count, 1) + let entry = entries[0] + XCTAssertEqual(entry["name"] as? String, "aliases") + XCTAssertEqual(entry["kind"] as? String, "array") + XCTAssertEqual( + try jsonText(entry["default"] as Any), try jsonText(["Ada", "Grace"]), + "an array in a declared `default` is data, not a named entry") + XCTAssertEqual((entry["items"] as? [String: Any])?["kind"] as? String, "string") + } + + // MARK: - Rule 1: map-form object entries still gain their name + + func testMapFormObjectEntriesTakeNameFromKey() throws { + let entries = try loadInputs( + [ + "name: t", + "inputs:", + " beta:", + " kind: boolean", + " alpha:", + " kind: string", + " description: first", + ].joined(separator: "\n")) + + // Keys are sorted so the canonical list is stable across dictionary + // iteration order. + XCTAssertEqual(entries.map { $0["name"] as? String }, ["alpha", "beta"]) + XCTAssertEqual(entries[0]["description"] as? String, "first") + } + + func testNestedMapFormPropertiesAreNormalized() throws { + let entries = try loadInputs( + [ + "name: t", + "inputs:", + " profile:", + " kind: object", + " properties:", + " nested: kept", + ].joined(separator: "\n")) + + XCTAssertEqual(entries.count, 1) + let nested = entries[0]["properties"] as? [[String: Any]] + XCTAssertEqual(nested?.count, 1) + XCTAssertEqual(nested?.first?["name"] as? String, "nested") + XCTAssertEqual(nested?.first?["kind"] as? String, "string") + XCTAssertEqual( + nested?.first?["default"] as? String, "kept", + "the shorthand applies at every nesting depth, not just the top level") + XCTAssertNil(nested?.first?["example"]) + } + + // MARK: - Helper + + private func jsonText(_ value: Any) throws -> String { + let data = try JSONSerialization.data(withJSONObject: ["v": value], options: [.sortedKeys]) + return String(decoding: data, as: UTF8.self) + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/NamedCollectionVectorTests.swift b/runtime/swift/prompty/Tests/PromptyTests/NamedCollectionVectorTests.swift new file mode 100644 index 000000000..f7fe6bb6f --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/NamedCollectionVectorTests.swift @@ -0,0 +1,1120 @@ +import Foundation + +import PromptyModel + +import XCTest + +/// Drives the canonical cross-runtime named-collection vector. +/// +/// `spec/vectors/model/named_collection_vectors.json` (PR #447) pins how +/// `inputs`, `outputs`, and nested `properties` behave when they are written as +/// a *name-keyed map* rather than a list: +/// +/// - the key supplies `name`, +/// - a bare scalar value infers `kind` and is stored in **`default`**, with +/// `example` left absent, +/// - an immediate **array** value is never a property and is rejected with the +/// full dotted path to the offending entry, +/// - arrays nested inside declared property fields (`default`, `items`) stay +/// valid — only the immediate named-entry position is closed. +/// +/// This is deliberately **not** the direct `@coerce` contract that +/// `PropertyScalarCoercionVectorTests` asserts. There a bare scalar loaded +/// straight through the generated `Property` model lands in `example`. The two +/// contracts share a surface — "a scalar became a property" — and differ in the +/// field they populate, so a runtime that conflates them passes a naive check +/// while breaking one of the two. This suite pins `default` *and* the absence +/// of `example`; the sibling suite pins `example` *and* the absence of +/// `default`. +/// +/// ## Why this asserts rather than skips +/// +/// The scalar-coercion sibling is blocked at the pinned emitter because the +/// generated `Property.load` cannot accept a bare scalar at all. The +/// named-collection form is different: the generated model never sees the map, +/// because `Loader` normalizes it into the canonical list first. Every defect +/// the fixture describes was therefore in this repository's hand-written +/// loader, not in generated code, and is fixed directly. +/// +/// One save-side clause remains outside the loader's reach: +/// `collectionFormat: "object"` asks `save()` to re-emit a name-keyed map when +/// every entry has a unique non-empty name. That is generated code, so those +/// vectors record a *documented blocked baseline* tied to the emitter pin — +/// their entry semantics are still asserted in full. +/// +/// ## Robustness to a moving fixture +/// +/// This file has already been rewritten several times upstream, so the suite +/// does not pin its vector count or ordering. It instead requires the vectors +/// the request names, and processes every vector present — an unrecognised +/// `operation` fails rather than being skipped, so a future clause cannot slip +/// through unasserted. +@testable import Prompty + +final class NamedCollectionVectorTests: XCTestCase { + + private static let vectorPath = "model/named_collection_vectors.json" + + /// The emitter pin whose known save-side gap the blocked baseline describes. + private static let blockedAtEmitterVersion = "0.4.2" + + /// Vectors this request names explicitly. The fixture may grow; it may not + /// quietly lose one of these. + private static let requiredVectorNames: Set = [ + "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", + "scalar_array_shorthand_in_name_keyed_inputs_is_rejected", + "array_value_in_name_keyed_inputs_is_rejected", + "array_value_in_nested_properties_is_rejected", + ] + + // MARK: - The vector + + func testCanonicalNamedCollectionVector() throws { + // Only genuine absence may skip. Routing every error through a `try?` would + // turn malformed JSON or a reshaped root into a green run. + let vectorURL = + Spec.root + .appendingPathComponent("vectors") + .appendingPathComponent("model") + .appendingPathComponent("named_collection_vectors.json") + + guard FileManager.default.fileExists(atPath: vectorURL.path) else { + throw XCTSkip( + "spec/vectors/\(Self.vectorPath) is not on this branch yet (PR #447 is " + + "unmerged), so the canonical named-collection fixture cannot be " + + "asserted. This suite activates automatically when the file lands; " + + "NamedCollectionShorthandTests covers the same rules meanwhile.") + } + + let document = try Spec.vectorObject(Self.vectorPath) + let vectors = try Self.validateVectorShape(document) + + var failures: [String] = [] + var blocked: [String] = [] + var asserted: [String] = [] + + for vector in vectors { + guard let name = vector["name"] as? String else { + failures.append("a vector declares no `name`") + continue + } + guard let operation = vector["operation"] as? String else { + failures.append("\(name): declares no `operation`") + continue + } + guard let input = vector["input"] as? [String: Any] else { + failures.append("\(name): declares no `input` object") + continue + } + guard let expected = vector["expected"] as? [String: Any] else { + failures.append("\(name): declares no `expected` object") + continue + } + + switch operation { + case "load-error": + Self.runLoadError( + name: name, input: input, expected: expected, + failures: &failures, asserted: &asserted) + case "load-save-reload": + // `collectionPath` is a sibling of `expected`, not a member of it. + guard let collectionPath = vector["collectionPath"] as? String else { + failures.append( + "\(name): a load-save-reload vector declares no `collectionPath`, so " + + "there is no way to know which collection it pins") + continue + } + Self.runLoadSaveReload( + name: name, input: input, expected: expected, collectionPath: collectionPath, + failures: &failures, blocked: &blocked, asserted: &asserted) + default: + // A new operation must not ride along unasserted. + failures.append( + "\(name): unrecognised operation `\(operation)`. The fixture grew a " + + "clause this suite does not evaluate, which would otherwise pass " + + "silently.") + } + } + + if !blocked.isEmpty { + // Tie the documented baseline to the pin it describes, so bumping the + // emitter without closing the gap fails instead of resting on stale prose. + let pin = Self.pinnedEmitterVersion() + if pin != Self.blockedAtEmitterVersion { + failures.append( + "the object-form save baseline is documented for " + + "@typra/emitter@\(Self.blockedAtEmitterVersion), but schema/package.json " + + "now pins \(pin ?? ""). Re-evaluate whether generated " + + "save() can emit the name-keyed object form: \(blocked.joined(separator: "; "))") + } + } + + XCTAssertTrue( + failures.isEmpty, + "named-collection vector failures:\n - " + failures.joined(separator: "\n - ")) + + // Non-vacuity: the fixture must actually have exercised the runtime. + XCTAssertFalse( + asserted.isEmpty, + "no named-collection vector was asserted — the fixture parsed but drove " + + "nothing, so this suite would report success while measuring nothing") + } + + // MARK: - load-error + + private static func runLoadError( + name: String, input: [String: Any], expected: [String: Any], + failures: inout [String], asserted: inout [String] + ) { + let contents: String + do { + contents = try frontmatter(input) + } catch { + failures.append("\(name): could not render input as frontmatter: \(error)") + return + } + + do { + _ = try Loader.load(contents: contents, basePath: FileManager.default.currentDirectoryPath) + failures.append( + "\(name): expected the load to be rejected, but it succeeded. The " + + "invalid entry was accepted silently.") + return + } catch let error as LoadError { + let token = expected["error"] as? String + guard token == "invalid-named-collection-entry" else { + // A future error class this suite does not model structurally: the + // rejection itself is still asserted, and recorded as such. + asserted.append("\(name) (throw only)") + return + } + guard case .invalidNamedCollectionEntry(let path, let category) = error else { + failures.append( + "\(name): expected LoadError.invalidNamedCollectionEntry, got \(error). " + + "A generic rejection does not carry the path and value category the " + + "contract requires.") + return + } + if let expectedPath = expected["path"] as? String, path != expectedPath { + failures.append("\(name): path expected `\(expectedPath)`, got `\(path)`") + } + if let expectedCategory = expected["valueCategory"] as? String, category != expectedCategory { + failures.append( + "\(name): valueCategory expected `\(expectedCategory)`, got `\(category)`") + } + // The machine-readable token must reach a consumer reading the message. + if !String(describing: error).contains("invalid-named-collection-entry") { + failures.append( + "\(name): the rendered diagnostic does not contain the " + + "`invalid-named-collection-entry` token: \(error)") + } + asserted.append(name) + } catch { + failures.append("\(name): rejected with a non-LoadError: \(error)") + } + } + + // MARK: - load-save-reload + + private static func runLoadSaveReload( + name: String, input: [String: Any], expected: [String: Any], collectionPath: String, + failures: inout [String], blocked: inout [String], asserted: inout [String] + ) { + let saved: [String: Any] + do { + let contents = try frontmatter(input) + let agent = try Loader.load( + contents: contents, basePath: FileManager.default.currentDirectoryPath) + saved = try agent.save() + } catch { + failures.append("\(name): load/save threw: \(error)") + return + } + + let rawCollection = saved[collectionPath] + let wireEntries: [[String: Any]] + let actualFormat: String + if let list = rawCollection as? [[String: Any]] { + wireEntries = list + actualFormat = "array" + } else if let map = rawCollection as? [String: Any] { + wireEntries = map.keys.sorted().compactMap { key in + var entry = (map[key] as? [String: Any]) ?? [:] + entry["name"] = key + return entry + } + actualFormat = "object" + } else { + failures.append( + "\(name): saved `\(collectionPath)` is neither an array nor an object: " + + "\(String(describing: rawCollection))") + return + } + + // --- collectionFormat ------------------------------------------------- + if let expectedFormat = expected["collectionFormat"] as? String, + expectedFormat != actualFormat + { + if expectedFormat == "object" && actualFormat == "array" { + // Generated `save()` always emits the ordered array. Lossless, but not + // yet the canonical wire form. Entry semantics below are still asserted. + blocked.append("\(name) (save emits array, contract wants object)") + } else { + failures.append( + "\(name): collectionFormat expected `\(expectedFormat)`, got `\(actualFormat)`") + } + } + + // --- wireEntries.absentFields (pre-reload wire shape) ------------------ + if let wireExpectations = expected["wireEntries"] as? [[String: Any]] { + for wireExpectation in wireExpectations { + guard let index = wireExpectation["index"] as? Int else { continue } + guard index < wireEntries.count else { + failures.append("\(name): wireEntries[\(index)] is out of range") + continue + } + // Read the raw list here: the object branch above synthesises `name` + // from the key, which would mask an absent-name expectation. + let rawEntry = (rawCollection as? [[String: Any]])?[index] ?? wireEntries[index] + for absent in (wireExpectation["absentFields"] as? [String]) ?? [] + where + rawEntry[absent] != nil + { + failures.append( + "\(name): wire entry \(index) should omit `\(absent)`, but it is present " + + "as \(String(describing: rawEntry[absent]))") + } + } + } + + // --- entries ---------------------------------------------------------- + guard let expectedEntries = expected["entries"] as? [[String: Any]] else { + asserted.append("\(name) (format only)") + return + } + + guard expectedEntries.count == wireEntries.count else { + failures.append( + "\(name): expected \(expectedEntries.count) entries, got \(wireEntries.count)") + return + } + + let absentEntryFields = (expected["absentEntryFields"] as? [String]) ?? [] + + for (index, expectedEntry) in expectedEntries.enumerated() { + let actualEntry = wireEntries[index] + let entryPath = "\(name).\(collectionPath)[\(index)]" + compare( + expected: canonicalizeExpected(expectedEntry), actual: actualEntry, + path: entryPath, failures: &failures) + + // `canonicalizeExpected` flattens a name-keyed nested `properties` map + // into the ordered list so the *semantic* comparison can proceed. That + // adaptation also erases the shape difference, so a nested collection + // saved as the array fallback would compare equal to a fixture stating + // the canonical object form. Walk the two in parallel to recover it. + blocked += nestedObjectFormGaps( + expected: expectedEntry, actual: actualEntry, path: entryPath) + + for absent in absentEntryFields where actualEntry[absent] != nil { + failures.append( + "\(entryPath): `\(absent)` must be absent, but it is " + + "\(String(describing: actualEntry[absent])). The named-collection scalar " + + "shorthand stores the value in `default`; populating `\(absent)` too " + + "would blur it with the direct @coerce contract.") + } + } + + // --- reload: the saved wire must load again and re-save identically ---- + do { + let reloaded = try Prompty.load(saved) + let resaved = try reloaded.save() + let before = try jsonText(saved) + let after = try jsonText(resaved) + if before != after { + failures.append( + "\(name): save/reload is not stable.\n first: \(before)\n second: \(after)") + } + } catch { + failures.append("\(name): the saved wire form did not reload: \(error)") + } + + asserted.append(name) + } + + // MARK: - Shape validation + + private static func validateVectorShape(_ document: [String: Any]) throws -> [[String: Any]] { + guard let vectors = document["vectors"] as? [[String: Any]] else { + throw VectorFailure( + "the fixture declares no `vectors` array; its shape changed and this " + + "suite would otherwise assert nothing") + } + guard !vectors.isEmpty else { + throw VectorFailure("the fixture declares zero vectors") + } + + let present = Set(vectors.compactMap { $0["name"] as? String }) + let missing = requiredVectorNames.subtracting(present).sorted() + guard missing.isEmpty else { + throw VectorFailure( + "the fixture no longer declares: \(missing.joined(separator: ", ")). " + + "These are the cases this gate exists to assert, so their absence is " + + "a fixture regression rather than a reason to pass.") + } + return vectors + } + + // MARK: - Nested object-form detection + // + // These run whether or not the canonical fixture is on the branch, so the + // detection above is exercised while `testCanonicalNamedCollectionVector` is + // still skipping on absence. + + /// The canonicalised comparison pipeline cannot see a nested object-form gap. + /// + /// This is the justification for `nestedObjectFormGaps` existing, pinned as a + /// test so it cannot quietly stop being true. The fixture states `properties` + /// as a name-keyed map — the canonical object form — while the runtime saved + /// the ordered array fallback. + /// + /// The blindness is created by `canonicalizeExpected`, not by `compare`: + /// `compare` on its own would see a map on one side and a list on the other. + /// Canonicalisation reshapes the expectation to the wire form first — which + /// is what lets the semantic assertions run at all — and that same adaptation + /// erases the shape difference. Both steps are exercised here together + /// because it is their composition that loses the signal. + func testCanonicalisedComparisonPipelineCannotSeeNestedObjectFormGap() { + let expected: [String: Any] = [ + "name": "location", + "kind": "object", + "properties": ["city": ["kind": "string"]], + ] + let actual: [String: Any] = [ + "name": "location", + "kind": "object", + "properties": [["name": "city", "kind": "string"]], + ] + + var failures: [String] = [] + Self.compare( + expected: Self.canonicalizeExpected(expected), actual: actual, path: "probe", + failures: &failures) + + XCTAssertTrue( + failures.isEmpty, + "the canonicalised pipeline was expected to be blind to this gap; if it " + + "now reports it, nestedObjectFormGaps may be redundant: \(failures)") + } + + /// The same pair, seen by the parallel walk. + func testNestedObjectFormGapIsDetected() { + let expected: [String: Any] = [ + "name": "location", + "kind": "object", + "properties": ["city": ["kind": "string"]], + ] + let actual: [String: Any] = [ + "name": "location", + "kind": "object", + "properties": [["name": "city", "kind": "string"]], + ] + + let gaps = Self.nestedObjectFormGaps(expected: expected, actual: actual, path: "probe") + + XCTAssertEqual(gaps.count, 1, "expected exactly one gap, got \(gaps)") + XCTAssertEqual( + gaps.first, "probe.properties (save emits array, contract wants object)") + } + + /// A fixture that states the array form is not a gap. + func testArrayFormExpectationIsNotReportedAsAGap() { + let expected: [String: Any] = [ + "name": "location", + "properties": [["name": "city", "kind": "string"]], + ] + let actual: [String: Any] = [ + "name": "location", + "properties": [["name": "city", "kind": "string"]], + ] + + XCTAssertTrue( + Self.nestedObjectFormGaps(expected: expected, actual: actual, path: "probe").isEmpty) + } + + /// A runtime that already emits the object form is not a gap either. + /// + /// Without this, the detection could be satisfied by always reporting, and + /// the baseline would never clear when the emitter is fixed. + func testObjectFormActualIsNotReportedAsAGap() { + let expected: [String: Any] = [ + "name": "location", + "properties": ["city": ["kind": "string"]], + ] + let actual: [String: Any] = [ + "name": "location", + "properties": ["city": ["kind": "string"]], + ] + + XCTAssertTrue( + Self.nestedObjectFormGaps(expected: expected, actual: actual, path: "probe").isEmpty) + } + + /// Gaps are found at every depth, not just the first. + /// + /// The coordinator's reported row is one level down (`inputs[0].properties`). + /// A detector that only checked the top nesting level would satisfy that row + /// while missing anything deeper. + func testNestedObjectFormGapsAreFoundAtEveryDepth() { + let expected: [String: Any] = [ + "name": "outer", + "properties": [ + "middle": [ + "kind": "object", + "properties": [ + "inner": ["kind": "object", "properties": ["leaf": ["kind": "string"]]] + ], + ] + ], + ] + let actual: [String: Any] = [ + "name": "outer", + "properties": [ + [ + "name": "middle", "kind": "object", + "properties": [ + [ + "name": "inner", "kind": "object", + "properties": [["name": "leaf", "kind": "string"]], + ] + ], + ] + ], + ] + + let gaps = Self.nestedObjectFormGaps(expected: expected, actual: actual, path: "probe") + + XCTAssertEqual( + gaps, + [ + "probe.properties (save emits array, contract wants object)", + "probe.properties.middle.properties (save emits array, contract wants object)", + "probe.properties.middle.properties.inner.properties " + + "(save emits array, contract wants object)", + ], + "every nesting level must be reported, with a path that locates it") + } + + /// A gap inside `items` is reported too. + func testObjectFormGapInsideItemsIsDetected() { + let expected: [String: Any] = [ + "name": "rows", + "kind": "array", + "items": ["kind": "object", "properties": ["cell": ["kind": "string"]]], + ] + let actual: [String: Any] = [ + "name": "rows", + "kind": "array", + "items": ["kind": "object", "properties": [["name": "cell", "kind": "string"]]], + ] + + XCTAssertEqual( + Self.nestedObjectFormGaps(expected: expected, actual: actual, path: "probe"), + ["probe.items.properties (save emits array, contract wants object)"]) + } + + /// Descent pairs children by name, not by position. + /// + /// A map has no order, so pairing the sorted expectation against the saved + /// list positionally would compare unrelated children whenever the runtime's + /// declaration order differs from alphabetical. + func testDescentPairsChildrenByNameNotPosition() { + let expected: [String: Any] = [ + "name": "outer", + "properties": [ + "alpha": ["kind": "string"], + "beta": ["kind": "object", "properties": ["leaf": ["kind": "string"]]], + ], + ] + // Saved in declaration order, which is the reverse of alphabetical. + let actual: [String: Any] = [ + "name": "outer", + "properties": [ + [ + "name": "beta", "kind": "object", + "properties": [["name": "leaf", "kind": "string"]], + ], + ["name": "alpha", "kind": "string"], + ], + ] + + let gaps = Self.nestedObjectFormGaps(expected: expected, actual: actual, path: "probe") + + XCTAssertEqual( + gaps, + [ + "probe.properties (save emits array, contract wants object)", + "probe.properties.beta.properties (save emits array, contract wants object)", + ], + "the nested gap under `beta` must be found even though `beta` is not the " + + "child that sorts first") + } + + /// A nested gap is still found when the saved child omits its `name`. + /// + /// An unnamed composite is exactly the case the canonical fixture names, so + /// pairing purely by name would drop the child that matters most and report + /// nothing for anything beneath it. + func testNestedObjectFormGapIsFoundWhenTheSavedChildOmitsItsName() { + let expected: [String: Any] = [ + "name": "outer", + "properties": [ + "inner": ["kind": "object", "properties": ["leaf": ["kind": "string"]]] + ], + ] + let actual: [String: Any] = [ + "name": "outer", + // The saved child carries no `name`, so it cannot be looked up by key. + "properties": [ + ["kind": "object", "properties": [["name": "leaf", "kind": "string"]]] + ], + ] + + let gaps = Self.nestedObjectFormGaps(expected: expected, actual: actual, path: "probe") + + XCTAssertEqual( + gaps, + [ + "probe.properties (save emits array, contract wants object)", + "probe.properties.inner.properties (save emits array, contract wants object)", + ], + "the gap beneath an unnamed saved child must still be reported") + } + + /// A positional fallback never binds a child that names a different key. + /// + /// Object form is keyed, so position is only a sound last resort for an + /// entry that omits `name`. Binding a differently-named entry would descend + /// into an unrelated child and report its gaps against the wrong key. + func testPositionalFallbackRefusesDifferentlyNamedChild() { + let expected: [String: Any] = [ + "name": "outer", + "properties": [ + "alpha": ["kind": "object", "properties": ["leaf": ["kind": "string"]]] + ], + ] + // The sole saved child names a different key and carries a nested gap of + // its own. Pairing it to `alpha` would misattribute that gap. + let actual: [String: Any] = [ + "name": "outer", + "properties": [ + ["name": "beta", "kind": "object", "properties": [["name": "leaf", "kind": "string"]]] + ], + ] + + XCTAssertEqual( + Self.nestedObjectFormGaps(expected: expected, actual: actual, path: "probe"), + ["probe.properties (save emits array, contract wants object)"], + "a differently-named child must not be bound by position") + } + + /// Elimination pairing finds a nameless child at a non-corresponding index. + /// + /// `alpha` resolves by name to index 1, leaving `beta` unmatched. Index-based + /// pairing would inspect index 1, find `alpha` there, and never reach the + /// nameless child at index 0 — silently losing beta's gap. Elimination pairs + /// the single unmatched key to the single nameless child regardless of where + /// it sits. + func testEliminationPairingIgnoresPosition() { + let expected: [String: Any] = [ + "name": "outer", + "properties": [ + "alpha": ["kind": "string"], + "beta": ["kind": "object", "properties": ["leaf": ["kind": "string"]]], + ], + ] + // beta's child is saved first and unnamed; alpha's is saved second. + let actual: [String: Any] = [ + "name": "outer", + "properties": [ + ["kind": "object", "properties": [["name": "leaf", "kind": "string"]]], + ["name": "alpha", "kind": "string"], + ], + ] + + XCTAssertEqual( + Self.nestedObjectFormGaps(expected: expected, actual: actual, path: "probe"), + [ + "probe.properties (save emits array, contract wants object)", + "probe.properties.beta.properties (save emits array, contract wants object)", + ], + "a nameless child must be found by elimination, not by index") + } + + /// Two nameless children are ambiguous, so neither is guessed. + /// + /// Attributing a gap to a key that cannot be established is the same + /// misattribution keyed pairing exists to prevent. The top-level array-form + /// gap is still reported, so the real problem stays visible. + func testAmbiguousNamelessChildrenAreNotGuessed() { + let nested: [String: Any] = ["kind": "object", "properties": ["leaf": ["kind": "string"]]] + let expected: [String: Any] = [ + "name": "outer", + "properties": ["alpha": nested, "beta": nested], + ] + let savedChild: [String: Any] = [ + "kind": "object", "properties": [["name": "leaf", "kind": "string"]], + ] + let actual: [String: Any] = [ + "name": "outer", + "properties": [savedChild, savedChild], + ] + + XCTAssertEqual( + Self.nestedObjectFormGaps(expected: expected, actual: actual, path: "probe"), + ["probe.properties (save emits array, contract wants object)"], + "ambiguous nameless children must not be paired to arbitrary keys") + } + + /// Duplicate names disqualify name-addressing entirely. + /// + /// Both saved children claim `alpha`, so building the index first would let + /// the second silently overwrite the first and the descent would then report + /// a gap found in whichever entry happened to survive. With duplicates + /// present there is no way to tell which `alpha` the contract meant, so + /// descending into the survivor is a guess dressed up as a match. The + /// pre-scan refuses the index and only the honest top-level gap is reported. + func testDuplicateNamesDisqualifyNameAddressing() { + let expected: [String: Any] = [ + "name": "outer", + "properties": ["alpha": ["kind": "object", "properties": ["leaf": ["kind": "string"]]]], + ] + // Two entries named `alpha`; only the second carries a nested collection, + // so a last-wins collapse would surface a gap the contract cannot attribute. + let actual: [String: Any] = [ + "name": "outer", + "properties": [ + ["name": "alpha", "kind": "string"], + ["name": "alpha", "kind": "object", "properties": [["name": "leaf", "kind": "string"]]], + ], + ] + + XCTAssertEqual( + Self.nestedObjectFormGaps(expected: expected, actual: actual, path: "probe"), + ["probe.properties (save emits array, contract wants object)"], + "duplicate names must disqualify the name index instead of collapsing last-wins") + } + + /// Duplicates must not decay into an elimination match. + /// + /// Refusing the index by returning an *empty* one would leave `alpha` + /// unmatched, and the lone nameless child would then look like its unique + /// elimination partner — so hiding `alpha` behind a duplicate would conjure a + /// match that direct addressing correctly denied. The nameless child here + /// belongs to neither `alpha`, so no nested gap may be reported. + func testDuplicateNamesAlsoSuppressEliminationPairing() { + let expected: [String: Any] = [ + "name": "outer", + "properties": ["alpha": ["kind": "object", "properties": ["leaf": ["kind": "string"]]]], + ] + let actual: [String: Any] = [ + "name": "outer", + "properties": [ + ["name": "alpha", "kind": "string"], + ["name": "alpha", "kind": "string"], + ["kind": "object", "properties": [["name": "leaf", "kind": "string"]]], + ], + ] + + XCTAssertEqual( + Self.nestedObjectFormGaps(expected: expected, actual: actual, path: "probe"), + ["probe.properties (save emits array, contract wants object)"], + "an unaddressable collection must suppress elimination, not just name lookup") + } + + /// A malformed `name` refuses the index rather than passing as nameless. + /// + /// A non-string `name` is dropped by the uniqueness scan yet is not nameless + /// either, so without an explicit refusal it vanishes from both pairing paths + /// and the *other* child is eliminated into `alpha` unopposed. + func testMalformedNameRefusesNameAddressing() { + let expected: [String: Any] = [ + "name": "outer", + "properties": ["alpha": ["kind": "object", "properties": ["leaf": ["kind": "string"]]]], + ] + let actual: [String: Any] = [ + "name": "outer", + "properties": [ + ["name": 123, "kind": "string"], + ["kind": "object", "properties": [["name": "leaf", "kind": "string"]]], + ], + ] + + XCTAssertEqual( + Self.nestedObjectFormGaps(expected: expected, actual: actual, path: "probe"), + ["probe.properties (save emits array, contract wants object)"], + "a malformed name must refuse the index instead of counting as nameless") + } + + /// Duplicate *expected* names cannot be matched against a keyed actual. + /// + /// The object form holds one `alpha`, so attributing it to both expected + /// occurrences would report the same nested gap twice against entries that + /// were never separately observed. Uniqueness is required on both sides. + func testDuplicateExpectedNamesRefuseObjectFormPairing() { + let expected: [String: Any] = [ + "name": "outer", + "properties": [ + ["name": "alpha", "kind": "object", "properties": ["left": ["kind": "string"]]], + ["name": "alpha", "kind": "object", "properties": ["right": ["kind": "string"]]], + ], + ] + let actual: [String: Any] = [ + "name": "outer", + "properties": [ + "alpha": ["kind": "object", "properties": [["name": "leaf", "kind": "string"]]] + ], + ] + + XCTAssertEqual( + Self.nestedObjectFormGaps(expected: expected, actual: actual, path: "probe"), + [], + "a single actual child must not be attributed to several expected entries") + } + + /// Nested named collections are saved in alphabetical order. + /// + /// Object-form named collections are keyed rather than order-bearing, so + /// this is not a contract assertion about that form. It pins the *array* + /// save form the runtime currently emits, which does carry order: + /// `canonicalizeExpected` sorts a name-keyed expectation into a list before + /// `compare` pairs it positionally, and that pairing stays sound only while + /// the runtime orders nested collections by name. If the emitter ever + /// switches to declaration order, this fails directly instead of surfacing + /// as a confusing field mismatch somewhere downstream. + func testNestedNamedCollectionsSaveInAlphabeticalOrder() throws { + let input: [String: Any] = [ + "name": "v", "model": ["id": "gpt-4"], + "inputs": [ + "outer": [ + "kind": "object", + // Deliberately not alphabetical in the source. + "properties": [ + "zebra": ["kind": "string"], + "apple": ["kind": "string"], + "middle": ["kind": "string"], + ], + ] + ], + ] + + let agent = try Loader.load( + contents: Self.frontmatter(input), basePath: FileManager.default.currentDirectoryPath) + let saved = try agent.save() + + let entries = (saved["inputs"] as? [[String: Any]]) ?? [] + let nested = (entries.first?["properties"] as? [[String: Any]]) ?? [] + XCTAssertEqual( + nested.compactMap { $0["name"] as? String }, ["apple", "middle", "zebra"], + "nested named collections are expected to save in alphabetical order") + } + + /// The detection is actually wired into the vector path, not just callable. + /// + /// Every test above exercises `nestedObjectFormGaps` directly, so all of them + /// would still pass if the call in `runLoadSaveReload` were deleted. This + /// drives the real vector path with a synthetic vector and asserts the gap + /// arrives in `blocked`, which is the list tied to the emitter pin. + func testNestedGapReachesBlockedThroughTheVectorPath() { + let input: [String: Any] = [ + "name": "v", "model": ["id": "gpt-4"], + "inputs": [ + "location": ["kind": "object", "properties": ["street": ["kind": "string"]]] + ], + ] + let expected: [String: Any] = [ + "entries": [ + [ + "name": "location", "kind": "object", + "properties": ["street": ["kind": "string"]], + ] + ] + ] + + var failures: [String] = [] + var blocked: [String] = [] + var asserted: [String] = [] + Self.runLoadSaveReload( + name: "synthetic", input: input, expected: expected, collectionPath: "inputs", + failures: &failures, blocked: &blocked, asserted: &asserted) + + XCTAssertEqual(failures, [], "the synthetic vector must otherwise pass") + XCTAssertEqual(asserted, ["synthetic"]) + XCTAssertTrue( + blocked.contains("synthetic.inputs[0].properties (save emits array, contract wants object)"), + "the nested gap must reach the pin-tied baseline through the real vector " + + "path, not only through direct calls: \(blocked)") + } + + // MARK: - Helpers + + /// Render a vector input as `.prompty` frontmatter. + /// + /// JSON is a subset of YAML, so the vector's own JSON is used verbatim rather + /// than re-serialised through a YAML writer that could reinterpret scalars. + private static func frontmatter(_ input: [String: Any]) throws -> String { + let data = try JSONSerialization.data(withJSONObject: input, options: []) + return "---\n" + String(decoding: data, as: UTF8.self) + "\n---\nsystem:\nvector\n" + } + + /// Adapt an expected entry's *shape* to the saved wire form. + /// + /// Only a name-keyed `properties` map is rewritten into the ordered named + /// list the wire uses. No value is altered and nothing is dropped, so every + /// semantic assertion still originates in the fixture. This is done locally + /// rather than by calling `Loader` so the comparison cannot become + /// self-referential. + private static func canonicalizeExpected(_ entry: [String: Any]) -> [String: Any] { + var result = entry + if let nested = entry["properties"] as? [String: Any] { + result["properties"] = nested.keys.sorted().map { key -> [String: Any] in + var child = (nested[key] as? [String: Any]) ?? [:] + child["name"] = key + return canonicalizeExpected(child) + } + } else if let nested = entry["properties"] as? [[String: Any]] { + result["properties"] = nested.map(canonicalizeExpected) + } + if let items = entry["items"] as? [String: Any] { + result["items"] = canonicalizeExpected(items) + } + return result + } + + /// Nested collections the fixture states in canonical object form that the + /// runtime saved as the ordered array fallback. + /// + /// The top-level `collectionFormat` clause records this gap for the + /// collection named by `collectionPath`. Nested collections have no such + /// clause, and `canonicalizeExpected` adapts their shape before comparison, + /// so without this walk a nested array fallback is indistinguishable from a + /// nested canonical object. Returned paths join the `blocked` baseline, which + /// is tied to the emitter pin — so bumping the pin without closing the gap + /// fails rather than resting on stale prose. + /// + /// Only the fixture's own structure drives this: a nested map is read as a + /// request for object form, a nested list as a request for the array form. + /// Nothing is inferred about collections the fixture does not mention. + private static func nestedObjectFormGaps( + expected: [String: Any], actual: [String: Any], path: String + ) -> [String] { + var gaps: [String] = [] + + if let expectedMap = expected["properties"] as? [String: Any] { + if actual["properties"] is [Any] { + gaps.append("\(path).properties (save emits array, contract wants object)") + } + // Descend by name: object-form named collections are keyed, not + // order-bearing, so a key is the only sound way to address a child. + // The one child a key cannot address is an entry that omits `name` in + // the array fallback (the unnamed-composite case). That entry is paired + // by ELIMINATION — a single unmatched key facing a single nameless + // child — never by index, because object form carries no positional + // identity. Anything more ambiguous is left unpaired rather than + // guessed, since attributing a gap to an unproven key is exactly the + // misattribution this pairing exists to avoid. + let sortedKeys = expectedMap.keys.sorted() + // A `nil` index means the collection is not soundly name-addressable — + // duplicate or malformed names. That has to suppress elimination as well, + // not just name lookup: an empty index would inflate `unmatchedKeys` + // until a lone nameless child looked like the unique partner of a lone + // unmatched key, binding a gap to a key that never owned it. Refusing + // both is the only reading that keeps "no proven owner" from decaying + // into "sole remaining candidate". + if let actualChildren = namedChildren(actual["properties"]) { + let actualList = (actual["properties"] as? [[String: Any]]) ?? [] + // Absence of the key, not a failed String cast: a malformed `name` is + // handled by `namedChildren` refusing the whole index above. + let namelessChildren = actualList.filter { $0["name"] == nil } + let unmatchedKeys = sortedKeys.filter { actualChildren[$0] == nil } + let eliminationChild = + (unmatchedKeys.count == 1 && namelessChildren.count == 1) ? namelessChildren[0] : nil + + for key in sortedKeys { + guard let expectedChild = expectedMap[key] as? [String: Any] else { continue } + var actualChild = actualChildren[key] + if actualChild == nil, unmatchedKeys.first == key { + actualChild = eliminationChild + } + guard let actualChild else { continue } + gaps += nestedObjectFormGaps( + expected: expectedChild, actual: actualChild, path: "\(path).properties.\(key)") + } + } + } else if let expectedList = expected["properties"] as? [[String: Any]] { + if let actualList = actual["properties"] as? [[String: Any]] { + // Both sides are ordered lists, so pair positionally exactly as + // `compare` does. Indexing by name here would drop children that omit + // `name` and collapse duplicates onto one another. + for (index, expectedChild) in expectedList.enumerated() where index < actualList.count { + gaps += nestedObjectFormGaps( + expected: expectedChild, actual: actualList[index], + path: "\(path).properties[\(index)]") + } + } else { + // Both sides must be unambiguous. Uniqueness of the *actual* keys is + // guaranteed by the object form, but the expected list can repeat a + // name, and each occurrence would then be attributed to the same single + // actual child — reporting a nested gap once per duplicate against + // entries that were never separately observed. + let expectedNames = expectedList.compactMap { $0["name"] as? String } + if let actualChildren = namedChildren(actual["properties"]), + Set(expectedNames).count == expectedNames.count + { + for (index, expectedChild) in expectedList.enumerated() { + guard let name = expectedChild["name"] as? String, + let actualChild = actualChildren[name] + else { continue } + gaps += nestedObjectFormGaps( + expected: expectedChild, actual: actualChild, path: "\(path).properties[\(index)]") + } + } + } + } + + if let expectedItems = expected["items"] as? [String: Any], + let actualItems = actual["items"] as? [String: Any] + { + gaps += nestedObjectFormGaps( + expected: expectedItems, actual: actualItems, path: "\(path).items") + } + + return gaps + } + + /// Index a nested `properties` collection by name, or `nil` when the + /// collection cannot be soundly addressed by name at all. + /// + /// The list form is keyed only after a **uniqueness pre-scan**, mirroring the + /// shared save rule that duplicate names force the whole-array fallback + /// *before* any map construction. Building the map first and letting a later + /// entry overwrite an earlier one would silently drop a child. + /// + /// Refusal is `nil` rather than an empty index because the two mean opposite + /// things to the caller: an empty index says "this collection has no named + /// children", which leaves every expected key unmatched and therefore + /// eligible for elimination pairing. An unaddressable collection must + /// suppress that path too, or a lone nameless child would be bound to a key + /// that duplicates merely hid. + /// + /// A malformed `name` — present but not a string — also refuses the index. It + /// would otherwise fall out of the uniqueness scan while also not counting as + /// nameless, disappearing from both pairing paths without trace. + private static func namedChildren(_ value: Any?) -> [String: [String: Any]]? { + if let map = value as? [String: Any] { + var result: [String: [String: Any]] = [:] + for (key, child) in map { + result[key] = (child as? [String: Any]) ?? [:] + } + return result + } + if let list = value as? [[String: Any]] { + let declared = list.filter { $0["name"] != nil } + let names = declared.compactMap { $0["name"] as? String } + guard names.count == declared.count else { return nil } + guard Set(names).count == names.count else { return nil } + var result: [String: [String: Any]] = [:] + for child in list { + if let name = child["name"] as? String { + result[name] = child + } + } + return result + } + return [:] + } + + /// Subset comparison: every field the fixture states must match exactly. + /// + /// Fields the runtime adds beyond the fixture are tolerated — the file is + /// still being revised upstream, and pinning its complement here would make + /// the gate brittle without making it stronger. + private static func compare( + expected: [String: Any], actual: [String: Any], path: String, failures: inout [String] + ) { + for key in expected.keys.sorted() { + let expectedValue = expected[key] as Any + let actualValue = actual[key] + + // An omitted wire `name` is the empty name in model terms. + if key == "name", actualValue == nil, (expectedValue as? String)?.isEmpty == true { + continue + } + + guard let actualValue else { + failures.append( + "\(path).\(key): missing, expected \((try? jsonText(expectedValue)) ?? "?")") + continue + } + + if let expectedDict = expectedValue as? [String: Any], + let actualDict = actualValue as? [String: Any] + { + compare( + expected: expectedDict, actual: actualDict, path: "\(path).\(key)", failures: &failures) + continue + } + + if let expectedList = expectedValue as? [[String: Any]], + let actualList = actualValue as? [[String: Any]] + { + guard expectedList.count == actualList.count else { + failures.append( + "\(path).\(key): expected \(expectedList.count) elements, got \(actualList.count)") + continue + } + for (index, element) in expectedList.enumerated() { + compare( + expected: element, actual: actualList[index], path: "\(path).\(key)[\(index)]", + failures: &failures) + } + continue + } + + // Scalars and heterogeneous lists compare as JSON text. `Spec.equal` + // is not used here: its Bool branch treats `0` and `false` as equal, + // which would let an integer default satisfy a boolean expectation. + let expectedText = (try? jsonText(expectedValue)) ?? "" + let actualText = (try? jsonText(actualValue)) ?? "" + if expectedText != actualText { + failures.append("\(path).\(key): expected \(expectedText), got \(actualText)") + } + } + } + + /// The `@typra/emitter` version `schema/package.json` pins. + private static func pinnedEmitterVersion() -> String? { + let url = + Spec.root + .deletingLastPathComponent() + .appendingPathComponent("schema") + .appendingPathComponent("package.json") + guard let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dependencies = object["dependencies"] as? [String: Any], + let version = dependencies["@typra/emitter"] as? String + else { + return nil + } + return version + } + + /// Render a value as JSON text, type faithfully. + private static func jsonText(_ value: Any) throws -> String { + let data = try JSONSerialization.data(withJSONObject: ["v": value], options: [.sortedKeys]) + return String(decoding: data, as: UTF8.self) + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/NonceCoercionTests.swift b/runtime/swift/prompty/Tests/PromptyTests/NonceCoercionTests.swift new file mode 100644 index 000000000..38dffff85 --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/NonceCoercionTests.swift @@ -0,0 +1,180 @@ +import XCTest + +@testable import Prompty + +/// Regression tests for strict-mode nonce handling. +/// +/// `generateNonce` emits 8 random bytes as 16 hex characters. Hex is a superset +/// of decimal, so coercing attribute values corrupted roughly 1 in 1,200 +/// generated nonces (measured: 165 failures in 200,000 trials). The round-trip +/// was not identity, so strict mode rejected its own untampered output with +/// "possible prompt injection detected". +/// +/// This surfaced as an intermittent failure of a live end-to-end test that has +/// no template inputs at all — the nonce was the only thing varying between +/// runs, and the throw happened in about a millisecond, before any network call. +/// +/// The nonces below were crafted to hit every distinct corruption mode, so these +/// tests fail deterministically if the exemption in `parseAttributes` is removed +/// rather than about once per thousand runs. +final class NonceCoercionTests: XCTestCase { + + /// Nonces that are shaped like numbers, one per corruption mode. + /// + /// Every string is a valid `generateNonce` output: 16 characters drawn from + /// `[0-9a-f]`. The final two are controls that were never at risk. + private static let numericLookingNonces: [(nonce: String, mode: String)] = [ + ("0123456789012345", "leading zero stripped by Int parsing"), + ("0419856025378190", "leading zero stripped by Int parsing"), + ("1234567890123456", "parses cleanly as Int"), + ("9789350921772800", "parses as Int, then normalizes to 9.7893509217728e+15"), + ("9677e80871924237", "scientific notation overflows Double to inf"), + ("0663512342083e99", "scientific notation, exponent within Double range"), + ("00000000000000e1", "leading zeros plus exponent"), + ("9e99999999999999", "exponent overflows Double to inf"), + ("45e12345678901ab", "trailing hex letters block numeric parsing"), + ("abcdef0123456789", "leading hex letter blocks numeric parsing"), + ] + + /// A nonce must survive the parser that produced it, whatever it looks like. + func testNumericLookingNoncesRoundTripThroughAttributeParsing() throws { + for (nonce, mode) in Self.numericLookingNonces { + let attributes = PromptyChatParser.parseAttributes("[nonce=\"\(nonce)\"]") + let parsed = attributes["nonce"] + + XCTAssertEqual( + parsed as? String, nonce, + "nonce \(nonce) was not preserved as a String (\(mode))" + ) + XCTAssertEqual( + JSONSupport.stringify(parsed), nonce, + "nonce \(nonce) did not round-trip to its original text (\(mode))" + ) + } + } + + /// The end-to-end symptom: strict parsing rejecting its own untampered output. + func testStrictParsingAcceptsNumericLookingNonces() throws { + for (nonce, mode) in Self.numericLookingNonces { + let rendered = """ + system[nonce="\(nonce)"]: + You are a helpful assistant. + + user[nonce="\(nonce)"]: + Hello. + """ + + let messages = try PromptyChatParser.parseChat(rendered, expectedNonce: nonce) + + XCTAssertEqual( + messages.count, 2, + "strict parsing dropped messages for nonce \(nonce) (\(mode))" + ) + XCTAssertEqual(messages.first?.role, .system) + XCTAssertEqual(messages.last?.role, .user) + } + } + + /// The exemption must not weaken injection detection, which is its whole point. + func testNumericLookingNonceMismatchIsStillRejected() { + let rendered = """ + system[nonce="0123456789012345"]: + You are a helpful assistant. + """ + + XCTAssertThrowsError( + try PromptyChatParser.parseChat(rendered, expectedNonce: "0123456789012346") + ) { error in + XCTAssertTrue( + "\(error)".contains("prompt injection"), + "expected an injection diagnostic, got \(error)" + ) + } + } + + /// Two nonces that coerce to the same number must not be conflated. + /// + /// `0123456789012345` and `123456789012345` differ only by a leading zero, and + /// under the old behavior both became the integer `123456789012345`. + /// + /// This is a canonicalization assertion, **not** a demonstrated bypass. The + /// expected nonce is taken straight from `generateNonce` and is never routed + /// through `parseAttributes`, so it is never coerced, and it is always exactly + /// 16 characters — the 15-character value used here cannot be produced. Under + /// the old code the collision therefore made validation fail closed (a + /// generated `0123456789012345` was rejected against its own expectation), + /// which is an availability defect rather than an authentication one. The + /// assertion is kept because collapsing distinct tokens onto one value is a + /// property worth pinning regardless of current exploitability. + func testNoncesDifferingOnlyByLeadingZeroAreNotConflated() { + let rendered = """ + system[nonce="0123456789012345"]: + You are a helpful assistant. + """ + + XCTAssertThrowsError( + try PromptyChatParser.parseChat(rendered, expectedNonce: "123456789012345") + ) { error in + XCTAssertTrue( + "\(error)".contains("prompt injection"), + "expected an injection diagnostic, got \(error)" + ) + } + } + + /// Every nonce this runtime can actually generate must validate. + /// + /// The crafted cases above pin known modes; this sweep guards against modes + /// nobody thought of. 20,000 trials against a measured 1-in-1,212 failure rate + /// makes a regression essentially certain to be caught. + func testGeneratedNoncesAlwaysValidate() throws { + for _ in 0..<20_000 { + let nonce = PromptyChatParser.generateNonce() + let attributes = PromptyChatParser.parseAttributes("[nonce=\"\(nonce)\"]") + + XCTAssertEqual( + JSONSupport.stringify(attributes["nonce"]), nonce, + "generated nonce \(nonce) did not survive attribute parsing" + ) + } + } + + /// Exempting `nonce` must not stop other attributes from being coerced. + /// + /// The bug is easy to "fix" by dropping coercion wholesale, which would + /// silently change the type of documented attributes such as `[index=1]`. + func testNonNonceAttributesAreStillCoerced() { + let attributes = PromptyChatParser.parseAttributes( + "[nonce=\"0123456789012345\",index=1,ratio=0.5,active=true,name=\"Alice\"]" + ) + + XCTAssertEqual(attributes["nonce"] as? String, "0123456789012345") + XCTAssertEqual(attributes["index"] as? Int, 1) + XCTAssertEqual(attributes["ratio"] as? Double, 0.5) + XCTAssertEqual(attributes["active"] as? Bool, true) + XCTAssertEqual(attributes["name"] as? String, "Alice") + } + + /// Non-nonce attributes still reach message metadata; the nonce still does not. + /// + /// This exercises `parseChat` on handcrafted rendered text rather than the + /// full strict pipeline, because `preRender` rebuilds each role marker with + /// only the nonce and drops any attributes the author wrote. That discards + /// `existing_attrs`, which `spec/spec.md:1141-1142` requires preserving. Rust + /// does the same (`parsers/prompty.rs:56`), so it is a cross-runtime + /// deviation, reported separately and deliberately not fixed here. + func testNonceIsStrippedFromMetadataWhileOtherAttributesSurvive() throws { + let nonce = "0123456789012345" + let rendered = """ + system[nonce="\(nonce)",index=1]: + You are a helpful assistant. + """ + + let messages = try PromptyChatParser.parseChat(rendered, expectedNonce: nonce) + + XCTAssertEqual(messages.count, 1) + let metadata = try XCTUnwrap(messages.first?.metadata) + XCTAssertNil(metadata["nonce"], "the nonce is a transport detail, not metadata") + XCTAssertEqual(metadata["index"] as? Int, 1) + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/OptionalCollectionPresenceTests.swift b/runtime/swift/prompty/Tests/PromptyTests/OptionalCollectionPresenceTests.swift new file mode 100644 index 000000000..2f3ceb65b --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/OptionalCollectionPresenceTests.swift @@ -0,0 +1,264 @@ +import Foundation + +import PromptyModel + +import XCTest + +@testable import Prompty + +/// Acceptance gate for the canonical *optional collection presence* rule. +/// +/// Presence of an optional collection is semantic, not cosmetic: +/// +/// - an **absent** collection must stay omitted on save; +/// - an **explicitly present empty** collection may save as empty; +/// - save must **never synthesize** an empty collection from absent input. +/// +/// The distinction matters because absent and empty mean different things to a +/// consumer. `enumValues: []` says "this property enumerates nothing", which is +/// a constraint; an omitted `enumValues` says "this property is not an +/// enumeration at all", which is the absence of one. A save that materializes +/// the schema default over an absent value destroys that distinction +/// irrecoverably — every subsequent round trip carries the invented `[]`, so no +/// later reader can tell it was never written. +/// +/// This is a *cross-runtime* rule rather than a Swift preference, so these +/// assertions are written against the required behaviour and not against the +/// current pin. Where the pinned emitter violates the rule the test records the +/// violation explicitly rather than tolerating it silently — see +/// `testAbsentEnumValuesIsNotSynthesizedOnSave`. +final class OptionalCollectionPresenceTests: XCTestCase { + + /// A composite property with no `enumValues` key at all. + /// + /// Composite subtypes are the interesting case: a scalar `Property` resolves + /// to the `.unknown` passthrough, which echoes its dictionary back and so + /// cannot synthesize anything. Only the generated composite structs have + /// stored properties that a schema default can populate. + private static func compositeSource(kind: String) -> [String: Any] { + var source: [String: Any] = ["name": "field_\(kind)", "kind": kind] + switch kind { + case "array": source["items"] = ["name": "item", "kind": "string"] + case "object": source["properties"] = [["name": "child", "kind": "string"]] + case "union": source["oneOf"] = [["name": "branch", "kind": "string"]] + default: break + } + return source + } + + private static let compositeKinds = ["array", "object", "union"] + + /// The subtype payload key each composite kind must retain. + private static let payloadKeys = ["array": "items", "object": "properties", "union": "oneOf"] + + /// Positive control proving the round trip actually exercised a composite. + /// + /// Every absence assertion in this file is satisfied by an empty dictionary, + /// so without this a regression that made `save()` return `[:]` would leave + /// the gates green while testing nothing. The enum-case check is the second + /// half: a scalar `Property` resolves to `.unknown`, which echoes its source + /// dictionary verbatim — so if `load` ever stopped recognising these + /// discriminators it would still reproduce `kind` and the payload, and only + /// the case tells the difference. + private func assertCompositeRoundTripped( + _ property: Property, + _ saved: [String: Any], + kind: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + switch (kind, property) { + case ("array", .arrayProperty), ("object", .objectProperty), ("union", .unionProperty): + break + default: + XCTFail( + "'\(kind)' did not load as its composite case — got \(property), so the " + + "absence assertions would be checking the .unknown passthrough instead", + file: file, line: line) + } + XCTAssertEqual( + saved["kind"] as? String, kind, "'\(kind)' lost its discriminator on save", + file: file, line: line) + XCTAssertEqual( + saved["name"] as? String, "field_\(kind)", "'\(kind)' lost its name on save", + file: file, line: line) + XCTAssertNotNil( + saved[Self.payloadKeys[kind] ?? ""], + "'\(kind)' lost its subtype payload on save, so the payload is empty rather than clean", + file: file, line: line) + } + + // MARK: - Absent must stay omitted + + /// The strict half of the rule, and the half that loses information when it + /// is broken. + /// + /// Passes at the current pin, so this is a preservation assertion rather than + /// a characterization of a known defect: it goes red if save ever starts + /// materializing the schema default over an absent value. + func testAbsentEnumValuesIsNotSynthesizedOnSave() throws { + for kind in Self.compositeKinds { + let source = Self.compositeSource(kind: kind) + XCTAssertNil(source["enumValues"], "fixture error: the source must omit enumValues") + + let property = try Property.load(source) + let saved = try property.save() + assertCompositeRoundTripped(property, saved, kind: kind) + + XCTAssertNil( + saved["enumValues"], + """ + \(kind) property synthesized 'enumValues' from absent input — \ + saved \(Spec.describe(saved["enumValues"])). Canonical rule: absent \ + optional collections must remain omitted; save must not synthesize an \ + empty collection from absent input. + """ + ) + } + } + + /// Absence must survive *repeated* saves, not just the first one. + /// + /// A save that is clean once but materializes the default on re-load would + /// still destroy the distinction in any pipeline that round trips twice, + /// which the loader does whenever a file is read, edited and written back. + func testAbsenceIsStableAcrossRepeatedRoundTrips() throws { + for kind in Self.compositeKinds { + var current = try Property.load(Self.compositeSource(kind: kind)).save() + for pass in 1...3 { + let property = try Property.load(current) + current = try property.save() + assertCompositeRoundTripped(property, current, kind: kind) + XCTAssertNil( + current["enumValues"], + "\(kind) property synthesized 'enumValues' on pass \(pass)" + ) + } + } + } + + /// The same rule at the document level: a prompt that declares no tools must + /// not acquire an empty `tools` collection by being saved. + func testAbsentToolsStaysOmitted() throws { + let saved = try Prompty.load([ + "kind": "prompt", + "name": "no-tools", + "instructions": "hello", + ]).save() + + XCTAssertNil( + saved["tools"], + "an absent 'tools' collection was synthesized as \(Spec.describe(saved["tools"]))" + ) + // Positive control: an empty save output would satisfy the assertion above + // while proving nothing. + XCTAssertEqual(saved["name"] as? String, "no-tools", "the prompt lost its name on save") + XCTAssertEqual( + saved["instructions"] as? String, "hello", "the prompt lost its instructions on save") + } + + /// The same rule for the other two document-level collections. + /// + /// `inputs` and `outputs` serialize through different paths than `tools` — the + /// empty-name save-form gate had to be proven separately on each for exactly + /// that reason — so absence has to be proven separately too. An + /// implementation can synthesize one while correctly omitting another. + func testAbsentInputsAndOutputsStayOmitted() throws { + let saved = try Prompty.load([ + "kind": "prompt", + "name": "no-io", + "instructions": "hello", + ]).save() + + XCTAssertNil( + saved["inputs"], + "an absent 'inputs' collection was synthesized as \(Spec.describe(saved["inputs"]))" + ) + XCTAssertNil( + saved["outputs"], + "an absent 'outputs' collection was synthesized as \(Spec.describe(saved["outputs"]))" + ) + // Positive control, as above: an empty save output would satisfy both + // assertions while proving nothing. + XCTAssertEqual(saved["name"] as? String, "no-io", "the prompt lost its name on save") + XCTAssertEqual( + saved["instructions"] as? String, "hello", "the prompt lost its instructions on save") + } + + // MARK: - Explicitly present empty may stay empty + + /// The permissive half. An author who writes `enumValues: []` has stated a + /// constraint, so dropping it is as lossy as inventing one. + /// + /// Asserted as "not silently dropped" rather than "exactly `[]`" because the + /// rule says an explicit empty *may* save as empty — it does not compel the + /// form. What it must not do is disappear. + func testExplicitlyEmptyEnumValuesIsNotDropped() throws { + for kind in Self.compositeKinds { + var source = Self.compositeSource(kind: kind) + source["enumValues"] = [Any]() + + let property = try Property.load(source) + let saved = try property.save() + assertCompositeRoundTripped(property, saved, kind: kind) + + guard let round = saved["enumValues"] else { + XCTFail( + """ + \(kind) property dropped an explicitly empty 'enumValues'. An explicit \ + empty collection is a stated constraint and must survive save. + """ + ) + continue + } + XCTAssertTrue( + (round as? [Any])?.isEmpty == true, + "\(kind) property changed an explicit empty 'enumValues' into \(Spec.describe(round))" + ) + } + } + + /// Absent and explicitly-empty must not converge on the same saved shape. + /// + /// This is the assertion that actually protects the *distinction*, as opposed + /// to the two halves above which each police one side of it. + /// + /// Stated as two directed assertions rather than "the two results differ", + /// because inequality is also satisfied when both sides are wrong in + /// different ways — an absent value that synthesized `[""]` and an explicit + /// empty that became `null` would compare unequal and pass a difference + /// check while having destroyed both halves of the rule. + func testAbsentAndExplicitlyEmptyDoNotConverge() throws { + for kind in Self.compositeKinds { + var present = Self.compositeSource(kind: kind) + present["enumValues"] = [Any]() + + let absentProperty = try Property.load(Self.compositeSource(kind: kind)) + let emptyProperty = try Property.load(present) + let fromAbsent = try absentProperty.save() + let fromEmpty = try emptyProperty.save() + assertCompositeRoundTripped(absentProperty, fromAbsent, kind: kind) + assertCompositeRoundTripped(emptyProperty, fromEmpty, kind: kind) + + XCTAssertNil( + fromAbsent["enumValues"], + """ + \(kind) property: the absent side must save as omitted, but saved \ + \(Spec.describe(fromAbsent["enumValues"])). + """ + ) + let values = try XCTUnwrap( + fromEmpty["enumValues"] as? [Any], + """ + \(kind) property: the explicitly-empty side must survive save as a \ + collection, but saved \(Spec.describe(fromEmpty["enumValues"])), so the \ + absent/empty distinction is unrecoverable after one round trip. + """ + ) + XCTAssertTrue( + values.isEmpty, + "\(kind) property invented \(values.count) entries in an explicitly empty 'enumValues'" + ) + } + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/ParseVectorTests.swift b/runtime/swift/prompty/Tests/PromptyTests/ParseVectorTests.swift new file mode 100644 index 000000000..ac88192c2 --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/ParseVectorTests.swift @@ -0,0 +1,96 @@ +import Foundation + +import PromptyModel + +import XCTest + +/// Conformance against `spec/vectors/parse/parse_vectors.json`. +/// +/// Drives the real `PromptyChatParser` and, for the thread vector, the real +/// `Pipeline.expandThreads` rather than a test-local reimplementation. +@testable import Prompty + +final class ParseVectorTests: XCTestCase { + + func testParseVectors() async throws { + Registry.shared.registerDefaults() + var run = VectorRun(stage: "parse") + + let agent = try Prompty.load([ + "kind": "prompt", + "name": "parse-vectors", + "template": ["format": ["kind": "jinja2"], "parser": ["kind": "prompty"]], + ]) + + for vector in try Spec.vectors("parse") { + let name = vector["name"] as? String ?? "" + run.started() + let input = vector["input"] as? [String: Any] ?? [:] + let expected = vector["expected"] as? [String: Any] ?? [:] + let rendered = input["rendered"] as? String ?? "" + + do { + // Vectors carry already-rendered text, so no nonce context exists and + // parsing runs in the non-strict path. + var messages = try await Pipeline.parse(agent, rendered: rendered) + + if let threadInputs = input["thread_inputs"] as? [String: Any] { + messages = Pipeline.expandThreads( + messages, + nonces: Self.nonces(in: rendered), + inputs: threadInputs + ) + } + + guard let expectedMessages = expected["messages"] as? [[String: Any]] else { continue } + try Self.compare(messages, expected: expectedMessages) + } catch { + run.fail(name, "\(error)") + } + } + + run.assertClean() + } + + /// Recover the `input name -> nonce` map the renderer would have produced. + private static func nonces(in rendered: String) -> [String: String] { + let pattern = "\(Defaults.threadNoncePrefix)[a-f0-9]+_(\\w+)__" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [:] } + + var result: [String: String] = [:] + let range = NSRange(rendered.startIndex..., in: rendered) + for match in regex.matches(in: rendered, range: range) { + guard + let full = Range(match.range, in: rendered), + let nameRange = Range(match.range(at: 1), in: rendered) + else { continue } + result[String(rendered[nameRange])] = String(rendered[full]) + } + return result + } + + /// Vectors assert on role plus the concatenated text of text parts, which is + /// the shared cross-runtime comparison. + private static func compare(_ actual: [Message], expected: [[String: Any]]) throws { + try expect( + actual.count == expected.count, + "message count: expected \(expected.count), got \(actual.count)\n actual: \(actual.map { "\($0.role.rawValue):\($0.textContent)" })" + ) + + for (index, expectedMessage) in expected.enumerated() { + let message = actual[index] + + if let role = expectedMessage["role"] as? String { + try expectEqual(message.role.rawValue, role, "messages[\(index)].role") + } + + let expectedText = + (expectedMessage["content"] as? [[String: Any]] ?? []) + .filter { ($0["kind"] as? String) == "text" } + .compactMap { $0["value"] as? String } + .joined() + + try expectEqual(message.textContent, expectedText, "messages[\(index)].content") + } + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/ProcessVectorTests.swift b/runtime/swift/prompty/Tests/PromptyTests/ProcessVectorTests.swift new file mode 100644 index 000000000..aad682d55 --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/ProcessVectorTests.swift @@ -0,0 +1,64 @@ +import Foundation + +import PromptyModel + +import XCTest + +@testable import Prompty + +/// Conformance against `spec/vectors/process/process_vectors.json`. +/// +/// Drives the real `OpenAIProcessor`, including the structured-output +/// finalization path that only engages when the agent declares outputs. +@testable import PromptyOpenAI + +final class ProcessVectorTests: XCTestCase { + + func testProcessVectors() async throws { + var run = VectorRun(stage: "process") + + for vector in try Spec.vectors("process") { + let name = vector["name"] as? String ?? "" + run.started() + let input = vector["input"] as? [String: Any] ?? [:] + let expected = vector["expected"] as? [String: Any] ?? [:] + + guard (input["provider"] as? String ?? "openai") == "openai" else { continue } + + do { + let agent = try Self.agent(hasOutputs: input["has_outputs"] as? Bool ?? false) + let result = try await OpenAIProcessor().process( + agent: agent, response: input["response"] as Any) + + // `""` and `null` are interchangeable in the shared expectations. + let expectedResult = expected["result"] + if Self.isEmptyish(result) && Self.isEmptyish(expectedResult) { continue } + + try expectEqual(result, expectedResult, "result") + } catch { + run.fail(name, "\(error)") + } + } + + run.assertClean() + } + + private static func agent(hasOutputs: Bool) throws -> Prompty { + var data: [String: Any] = [ + "kind": "prompt", + "name": "process-vectors", + "model": ["id": "gpt-4", "provider": "openai"], + "instructions": "test", + ] + if hasOutputs { + data["outputs"] = [["name": "result", "kind": "string"]] + } + return try Prompty.load(data) + } + + private static func isEmptyish(_ value: Any?) -> Bool { + if value == nil || value is NSNull { return true } + if let string = value as? String { return string.isEmpty } + return false + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/PropertyKindDispatchTests.swift b/runtime/swift/prompty/Tests/PromptyTests/PropertyKindDispatchTests.swift new file mode 100644 index 000000000..a124bc1a6 --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/PropertyKindDispatchTests.swift @@ -0,0 +1,148 @@ +import Foundation + +import PromptyModel + +import XCTest + +/// Measures how the generated `Property` enum dispatches every `kind` the +/// schema declares, including the scalar shorthands that `@coerce` defines. +/// +/// `schema/model/core/properties.tsp` declares `Property` as the +/// `@discriminator("kind")` base and gives it four `@coerce` forms: +/// +/// ```tsp +/// @coerce(string, #{ kind: "string", example: "{value}" }, "input", ...) +/// @coerce(integer, #{ kind: "integer", example: "{value}" }, "input", ..., 4) +/// @coerce(float32, #{ kind: "float", example: "{value}" }, "input", ...) +/// @coerce(boolean, #{ kind: "boolean", example: "{value}" }, "input", ...) +/// ``` +/// +/// Only `array`, `object` and `union` are separate models that `extends +/// Property`. The scalar kinds are the base model itself, and a bare scalar +/// is meant to coerce into it — `"Doe"` becomes `kind: "string"`, `example: +/// "Doe"`. +/// +/// @typra/emitter@0.4.2 emits neither half of that contract: the generated +/// enum carries only the three `extends` subtypes plus `.unknown`, and +/// `Property.load` calls `TypraRuntime.object` before it reads the +/// discriminator, so a bare scalar cannot be loaded at all. Scalar-kind +/// objects therefore land in `.unknown` and bare scalars throw. +/// +/// This matters well beyond the model package: `kind: string` is how ordinary +/// prompt inputs are declared, and the cross-runtime vectors under `spec/` +/// use the scalar kinds in 84 places. `.unknown` round-trips its payload +/// verbatim, so the vectors still pass and nothing fails loudly — which is +/// exactly why the gap needs a test that states it. +/// +/// These are characterization tests. When a fixed emitter lands scalar +/// dispatch, the two tripwires below fail on purpose. At that point treat +/// every `Property` consumer as suspect — `ModelExtensions.boundParameterNames` +/// and `PromptyOpenAI.Wire` both switch over these cases — then replace the +/// characterizations with the real assertions named in each message. +final class PropertyKindDispatchTests: XCTestCase { + + /// The scalar kinds, paired with the bare literal each one coerces from. + private static let scalarKinds: [(kind: String, literal: Any)] = [ + ("string", "Doe"), + ("integer", 4), + ("float", Double(3.5)), + ("boolean", true), + ] + + // MARK: - Structural kinds + + /// `array` / `object` / `union` are real subtypes and must resolve to their + /// typed cases. This is the control: it proves the discriminator switch + /// works, so a `.unknown` result below is a missing case rather than a + /// broken dispatch. + func testStructuralPropertyKindsDispatchToTypedCases() throws { + let structural: [String: [String: Any]] = [ + "array": ["items": ["name": "item", "kind": "string"]], + "object": ["properties": [["name": "child", "kind": "string"]]], + "union": ["anyOf": [["name": "branch", "kind": "string"]]], + ] + + for (kind, extra) in structural { + var source: [String: Any] = ["name": "field_\(kind)", "kind": kind] + for (key, value) in extra { source[key] = value } + + let loaded = try Property.load(source) + switch (kind, loaded) { + case ("array", .arrayProperty), ("object", .objectProperty), + ("union", .unionProperty): + break + default: + XCTFail("\(kind) did not dispatch to its typed case, got \(loaded)") + continue + } + + let saved = try loaded.save() + XCTAssertEqual(saved["kind"] as? String, kind, "\(kind): discriminator lost") + } + } + + // MARK: - Scalar kinds (tripwire) + + /// A scalar `kind` currently has no case of its own, so it falls through to + /// `.unknown`. The payload survives verbatim, which is why this is invisible + /// in the spec vectors. + func testScalarPropertyKindsFallThroughToUnknown() throws { + for (kind, _) in Self.scalarKinds { + let source: [String: Any] = [ + "name": "field_\(kind)", + "kind": kind, + "description": "a \(kind) input", + ] + + let loaded = try Property.load(source) + guard case .unknown(let raw) = loaded else { + XCTFail( + "\(kind) now dispatches to a typed case — the emitter grew scalar " + + "support. Re-audit every `Property` switch (ModelExtensions, " + + "PromptyOpenAI.Wire), then assert the typed case here instead") + continue + } + + // Verbatim preservation is the only reason the vectors stay green. + XCTAssertEqual(raw["kind"] as? String, kind, "\(kind): discriminator lost") + XCTAssertEqual(raw["name"] as? String, "field_\(kind)", "\(kind): name lost") + XCTAssertEqual( + raw["description"] as? String, "a \(kind) input", "\(kind): description lost") + + let saved = try loaded.save() + XCTAssertEqual(saved["kind"] as? String, kind, "\(kind): discriminator lost on save") + XCTAssertEqual(saved["name"] as? String, "field_\(kind)", "\(kind): name lost on save") + } + } + + // MARK: - @coerce shorthand (tripwire) + + /// The `@coerce` shorthand is not emitted, and `Property.load` demands an + /// object before it inspects `kind`, so a bare scalar throws rather than + /// becoming `#{ kind: ..., example: "{value}" }`. + /// + /// The specific error matters. Accepting any thrown error would let this + /// stay green if a partial emitter change moved the failure somewhere else + /// — the tripwire would then be pinning a coincidence rather than the + /// missing coercion. `Property.load` gates on `TypraRuntime.object` before + /// it reads the discriminator, so `invalidObject(type: "Property")` is the + /// one outcome that actually means "no shorthand support". + func testBareScalarShorthandIsNotCoerced() throws { + for (kind, literal) in Self.scalarKinds { + XCTAssertThrowsError( + try Property.load(literal), + "\(kind) shorthand now loads — the emitter grew @coerce support. " + + "Assert kind == \"\(kind)\" and example == the literal instead" + ) { error in + guard case TypraRuntimeError.invalidObject(let type) = error else { + XCTFail( + "\(kind) shorthand failed for an unrelated reason (\(error)) — the " + + "object gate in Property.load moved, so this test no longer " + + "measures missing @coerce support") + return + } + XCTAssertEqual(type, "Property", "\(kind): unexpected failing type") + } + } + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/PropertyScalarCoercionVectorTests.swift b/runtime/swift/prompty/Tests/PromptyTests/PropertyScalarCoercionVectorTests.swift new file mode 100644 index 000000000..07602230d --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/PropertyScalarCoercionVectorTests.swift @@ -0,0 +1,351 @@ +import Foundation + +import PromptyModel + +import XCTest + +/// Drives the canonical cross-runtime `Property` scalar-coercion vector. +/// +/// `spec/vectors/model/property_scalar_coercion_vectors.json` (PR #447) states +/// one atomic contract: loading a *bare scalar* directly through the generated +/// `Property` model infers the primitive `kind` and stores the scalar +/// unmodified in `example`. Four cases — string, integer, float, boolean — +/// and the vector is explicit that "all four cases are required together", so +/// partial support is a failure rather than progress. +/// +/// This is deliberately **not** the named-collection scalar shorthand. There a +/// scalar lands in `default` (`inputs: { lastName: Doe }`), and the two must +/// not be conflated: a loader that routed the direct form into `default` would +/// satisfy a naive "the scalar survived" check while breaking the contract. +/// `assertDirectCoercion` therefore pins `example` *and* the absence of +/// `default`. +/// +/// `PropertyKindDispatchTests` already characterises the same gap from the +/// generated side. This suite exists so the canonical fixture itself is +/// asserted continuously, rather than the runtime only agreeing with a +/// hand-written restatement of it that can drift from the shared file. +/// +/// ## Why this can skip +/// +/// Two independent things gate it, and they fail differently on purpose: +/// +/// 1. The vector is not on this branch yet — #447 is unmerged. Absence skips, +/// and the suite activates by itself the moment the file lands. Nothing +/// here vendors a private copy: a second copy of a shared contract is how +/// runtimes silently diverge. +/// 2. `@typra/emitter@0.4.2` — the pin this PR is held on — cannot satisfy the +/// contract at all. `Property.load` calls `TypraRuntime.object` before it +/// reads the discriminator, so every bare scalar throws +/// `invalidObject("Property")`. That is recorded as a *documented blocked +/// baseline*, not a pass. +/// +/// The blocked path is narrow by design: it is accepted only when all four +/// cases throw exactly that error. One case loading, or any other error, +/// fails loudly — a partially-supported coercion is an unmodelled state and +/// the atomicity clause makes it a defect. +final class PropertyScalarCoercionVectorTests: XCTestCase { + + private static let vectorPath = "model/property_scalar_coercion_vectors.json" + + /// The four cases the vector must declare, in order. + private static let requiredCaseNames = ["string", "integer", "float", "boolean"] + + /// The emitter pin whose known defect the blocked baseline describes. + private static let blockedAtEmitterVersion = "0.4.2" + + // MARK: - Outcome of one probe + + private enum Outcome { + /// Loaded; carries the saved wire form. + case loaded([String: Any]) + /// Threw exactly the documented pinned-emitter error. + case blocked + /// Threw something else — unmodelled. + case failed(String) + } + + // MARK: - The vector + + func testCanonicalScalarCoercionVector() throws { + // Absence is the only condition that may skip here. Reading through + // `Spec.vectorObject` and treating *any* thrown error as "not on this + // branch" would turn malformed JSON, a changed root type, or an unreadable + // file into a green run — the fixture would be broken and the suite would + // report success. Existence is checked separately so every other failure + // reaches the test as a failure. + let vectorURL = + Spec.root + .appendingPathComponent("vectors") + .appendingPathComponent("model") + .appendingPathComponent("property_scalar_coercion_vectors.json") + + guard FileManager.default.fileExists(atPath: vectorURL.path) else { + throw XCTSkip( + "spec/vectors/\(Self.vectorPath) is not on this branch yet (PR #447 is " + + "unmerged), so the canonical scalar-coercion fixture cannot be " + + "asserted. This suite activates automatically when the file lands; " + + "PropertyKindDispatchTests covers the same gap meanwhile.") + } + + let document = try Spec.vectorObject(Self.vectorPath) + + let cases = try Self.validateVectorShape(document) + + // Probe all four before judging any, so the atomicity clause can be + // evaluated across the whole set rather than aborting on the first case. + let outcomes = cases.map { probe in + Self.probe(probe.input) + } + + if let unmodelled = zip(cases, outcomes).compactMap({ probe, outcome -> String? in + guard case .failed(let detail) = outcome else { return nil } + return "\(probe.name): \(detail)" + }).first { + XCTFail( + "a scalar case failed for an unrelated reason, so this suite no longer " + + "measures scalar coercion — the object gate in Property.load moved. " + + "\(unmodelled)") + return + } + + let blocked = outcomes.filter { if case .blocked = $0 { return true } else { return false } } + + if blocked.count == outcomes.count { + // Tie the skip to the pin it describes. If the emitter is bumped and the + // gap survives, this stops being an explained baseline and becomes an + // unexamined one — so it fails rather than skipping under stale prose. + let pin = Self.pinnedEmitterVersion() + let expectedPin = Self.blockedAtEmitterVersion + let staleBaseline = + "all \(outcomes.count) scalar cases still throw " + + "invalidObject(\"Property\"), but schema/package.json now pins " + + "@typra/emitter@\(pin ?? "") rather than \(expectedPin). " + + "The documented baseline explains the gap for \(expectedPin) only, " + + "so it can no longer be skipped under that explanation: either the " + + "new emitter was meant to fix direct scalar coercion, or this " + + "baseline needs re-stating against it." + guard pin == expectedPin else { + XCTFail(staleBaseline) + return + } + throw XCTSkip( + "documented blocked baseline: all \(outcomes.count) scalar cases " + + "throw invalidObject(\"Property\") on @typra/emitter@\(expectedPin), " + + "the pin this PR is held on. Property.load gates on " + + "TypraRuntime.object before reading the discriminator and the " + + "generated enum has no scalar case, so direct coercion is absent " + + "rather than wrong. When a fixed emitter lands, this suite starts " + + "asserting the vector for real and PropertyKindDispatchTests' two " + + "tripwires fail on purpose.") + } + + if !blocked.isEmpty { + XCTFail( + "scalar coercion is only partially supported: \(blocked.count) of " + + "\(outcomes.count) cases still throw invalidObject(\"Property\") " + + "while the rest load. The vector requires all four cases together, " + + "so a partial emitter fix is a defect, not progress. Cases: " + + Self.describeOutcomes(cases, outcomes)) + return + } + + var run = VectorRun(stage: "property scalar coercion") + for (probe, outcome) in zip(cases, outcomes) { + run.check(probe.name) { + guard case .loaded(let saved) = outcome else { + throw VectorFailure("expected a loaded property, got \(outcome)") + } + try Self.assertDirectCoercion(saved: saved, probe: probe) + } + } + run.assertClean() + } + + // MARK: - Assertions + + /// Pin one case: inferred `kind`, verbatim `example`, and the absence of the + /// named-collection `default` the contract is explicitly distinct from. + private static func assertDirectCoercion(saved: [String: Any], probe: Probe) throws { + try expectEqual(saved["kind"], probe.expectedKind, "\(probe.name): inferred kind") + + guard let example = saved["example"], !(example is NSNull) else { + throw VectorFailure( + "\(probe.name): example is absent. The scalar must be stored in " + + "example; if it landed in default the direct form has been " + + "confused with the named-collection shorthand. saved: " + + Spec.describe(saved)) + } + + // Compare re-serialised JSON rather than Swift values: Foundation bridges + // JSON numbers and booleans to NSNumber, where `false` and `0` are easy to + // conflate with an `as?` cast. Round-tripping through JSONSerialization + // keeps `false` distinct from `0` and `3.14` distinct from `3`, which is + // the whole point of a coercion contract, and does so identically on + // Darwin and Linux. + let actualText = try jsonText(example) + let expectedText = try jsonText(probe.expectedExample) + guard actualText == expectedText else { + throw VectorFailure( + "\(probe.name): example must be stored unmodified.\n" + + " actual: \(actualText)\n expected: \(expectedText)") + } + + if let fallback = saved["default"], !(fallback is NSNull) { + throw VectorFailure( + "\(probe.name): default was populated with \(Spec.describe(fallback)). " + + "Direct scalar coercion targets example only — default is the " + + "named-collection shorthand, and the vector keeps the two distinct.") + } + } + + // MARK: - Probing + + /// Probe one scalar. + /// + /// `load` and `save` are classified separately on purpose. Wrapping both in + /// one `catch` would let a `save` that threw `invalidObject("Property")` be + /// read as the documented *load*-gate baseline, skipping the suite over a + /// defect it was built to catch. Only `load` can produce `.blocked`. + private static func probe(_ input: Any) -> Outcome { + let loaded: Property + do { + loaded = try Property.load(input) + } catch let error as TypraRuntimeError { + if case .invalidObject(let type) = error, type == "Property" { + return .blocked + } + return .failed("unexpected TypraRuntimeError from load: \(error)") + } catch { + return .failed("unexpected error from load: \(error)") + } + + do { + return .loaded(try loaded.save()) + } catch { + return .failed("loaded, then save() threw: \(error)") + } + } + + // MARK: - Vector shape + + private struct Probe { + let name: String + let input: Any + let expectedKind: String + let expectedExample: Any + } + + /// Validate the fixture itself before trusting it. + /// + /// Every expectation here is one the vector must *declare*. A shared file can + /// be emptied or reshaped upstream, and an expectation that silently + /// evaporates is worse than one that was never written: the suite keeps + /// reporting success over an assertion that no longer exists. + private static func validateVectorShape(_ document: [String: Any]) throws -> [Probe] { + guard let vectors = document["vectors"] as? [[String: Any]] else { + throw VectorFailure("vector file has no `vectors` array") + } + guard vectors.count == 1 else { + throw VectorFailure( + "expected exactly one atomic vector, found \(vectors.count) — the " + + "contract is a single all-or-nothing group") + } + + let vector = vectors[0] + guard let operation = vector["operation"] as? String, operation == "load" else { + throw VectorFailure( + "expected operation `load`, found \(Spec.describe(vector["operation"]))") + } + guard let rawCases = vector["cases"] as? [[String: Any]] else { + throw VectorFailure("vector declares no `cases` array") + } + guard rawCases.count == requiredCaseNames.count else { + throw VectorFailure( + "expected exactly \(requiredCaseNames.count) cases, found \(rawCases.count) — " + + "the contract is a fixed four-case group") + } + + // Read names strictly. `compactMap` would silently drop an unnamed case, + // so a fifth entry could hide behind four correct names. + let names = try rawCases.map { rawCase -> String in + guard let name = rawCase["name"] as? String else { + throw VectorFailure("a case declares no `name`") + } + return name + } + guard names == requiredCaseNames else { + throw VectorFailure( + "vector must declare exactly \(requiredCaseNames) in order, found " + + "\(names) — a dropped or reordered case would quietly narrow the " + + "contract this suite asserts") + } + + return try rawCases.map { rawCase in + let name = rawCase["name"] as? String ?? "" + guard let input = rawCase["input"] else { + throw VectorFailure("\(name): case declares no `input`") + } + guard let expected = rawCase["expected"] as? [String: Any] else { + throw VectorFailure("\(name): case declares no `expected` object") + } + guard let kind = expected["kind"] as? String else { + throw VectorFailure("\(name): expected block declares no `kind`") + } + guard kind == name else { + throw VectorFailure( + "\(name): expected kind is `\(kind)`; each case is named for the kind " + + "it pins, so a mismatch means the fixture drifted") + } + guard let example = expected["example"], !(example is NSNull) else { + throw VectorFailure( + "\(name): expected block declares no `example` — without it the case " + + "would assert the kind while ignoring the stored scalar") + } + return Probe(name: name, input: input, expectedKind: kind, expectedExample: example) + } + } + + // MARK: - Helpers + + /// The `@typra/emitter` version `schema/package.json` pins. + /// + /// Read at run time so the blocked-baseline skip cannot outlive the pin it + /// describes. Returns `nil` when the file is unreadable or reshaped, which + /// the caller treats as "not the documented pin". + private static func pinnedEmitterVersion() -> String? { + let url = + Spec.root + .deletingLastPathComponent() + .appendingPathComponent("schema") + .appendingPathComponent("package.json") + guard let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dependencies = object["dependencies"] as? [String: Any], + let version = dependencies["@typra/emitter"] as? String + else { + return nil + } + return version + } + + /// Render a value as JSON text, type faithfully. + /// + /// Wrapped in an object because a bare scalar is not a valid top-level + /// `JSONSerialization` payload on every platform. + private static func jsonText(_ value: Any) throws -> String { + let data = try JSONSerialization.data(withJSONObject: ["v": value], options: [.sortedKeys]) + return String(decoding: data, as: UTF8.self) + } + + private static func describeOutcomes(_ cases: [Probe], _ outcomes: [Outcome]) -> String { + zip(cases, outcomes) + .map { probe, outcome in + switch outcome { + case .loaded: return "\(probe.name)=loaded" + case .blocked: return "\(probe.name)=blocked" + case .failed(let detail): return "\(probe.name)=failed(\(detail))" + } + } + .joined(separator: ", ") + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/ReadmeSnippetTests.swift b/runtime/swift/prompty/Tests/PromptyTests/ReadmeSnippetTests.swift new file mode 100644 index 000000000..0248b9401 --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/ReadmeSnippetTests.swift @@ -0,0 +1,53 @@ +import Foundation + +import Prompty + +import PromptyModel + +import PromptyOpenAI + +/// Compiles the snippets in `runtime/swift/README.md`. +/// +/// The README claimed a `Prompty.load(path:)` entry point that never existed. +/// Documented calls are API surface, so they are type-checked here rather than +/// trusted. Nothing is executed — a compile is the whole assertion. +import XCTest + +final class ReadmeSnippetTests: XCTestCase { + + func testReadmeSnippetsCompile() throws { + func quickStart() async throws -> Any? { + Registry.shared.registerDefaults() + registerOpenAI() + + return try await Pipeline.invoke( + path: "basic.prompty", + inputs: ["question": "What is the capital of Iceland?"] + ) + } + + func stages(inputs: [String: Any]) async throws -> Any? { + let agent = try Loader.load(path: "basic.prompty") + let messages = try await Pipeline.prepare(agent, inputs: inputs) + let raw = try await Pipeline.run(agent, messages: messages) + return raw + } + + func toolLoop( + agent: Prompty, raw: Any?, inputs: [String: Any], + myTools: [String: ([String: Any]) throws -> String] + ) throws -> [String] { + var results: [String] = [] + for call in Pipeline.toolCalls(in: raw) { + let args = Pipeline.boundArguments(agent, call: call, inputs: inputs) + let result = try myTools[call.name]!(args) + results.append(result) + } + return results + } + + XCTAssertNotNil(quickStart) + XCTAssertNotNil(stages) + XCTAssertNotNil(toolLoop) + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/RegressionTests.swift b/runtime/swift/prompty/Tests/PromptyTests/RegressionTests.swift new file mode 100644 index 000000000..067aeccea --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/RegressionTests.swift @@ -0,0 +1,315 @@ +import Foundation + +import PromptyModel + +import XCTest + +/// Guards for defects found in review that the spec vectors do not cover. +/// +/// Each test names the behavior it protects, so a future regression reads as a +/// specific broken promise rather than an anonymous assertion failure. +@testable import Prompty + +/// A parser whose only job is to return a chosen `preRender` result. +final class RegressionTests: XCTestCase { + + // MARK: - Wildcard template kinds + + /// A prompt that configures only one half of `template` must still resolve + /// the other half to its default. + /// + /// The generated model fills an unset `kind` with the schema wildcard `"*"`, + /// which is never a registry key. Treating it as a real key made a prompt + /// that set only `format` fail to find a parser. + func testPartialTemplateFallsBackToDefaults() throws { + let formatOnly = try Prompty.load([ + "kind": "prompt", + "name": "format-only", + "template": ["format": ["kind": "mustache"]], + ]) + XCTAssertEqual(formatOnly.formatKind, "mustache") + XCTAssertEqual(formatOnly.parserKind, Defaults.parser) + + let parserOnly = try Prompty.load([ + "kind": "prompt", + "name": "parser-only", + "template": ["parser": ["kind": "prompty"]], + ]) + XCTAssertEqual(parserOnly.formatKind, Defaults.templateFormat) + XCTAssertEqual(parserOnly.parserKind, "prompty") + + let noTemplate = try Prompty.load(["kind": "prompt", "name": "bare"]) + XCTAssertEqual(noTemplate.formatKind, Defaults.templateFormat) + XCTAssertEqual(noTemplate.parserKind, Defaults.parser) + } + + /// An explicitly empty kind is as unset as a missing one. + func testEmptyTemplateKindFallsBackToDefaults() throws { + let agent = try Prompty.load([ + "kind": "prompt", + "name": "empty-kinds", + "template": ["format": ["kind": ""], "parser": ["kind": ""]], + ]) + XCTAssertEqual(agent.formatKind, Defaults.templateFormat) + XCTAssertEqual(agent.parserKind, Defaults.parser) + } + + // MARK: - Nonce ownership + + /// `prepare` must expand a thread input into real messages. + /// + /// Nonce substitution has to happen exactly once. When both the pipeline and + /// the renderer substituted, the renderer minted a second nonce, the pipeline + /// searched the output for its own now-absent first one, and every thread, + /// image, file, and audio input was silently dropped. + func testPrepareExpandsThreadInputs() async throws { + let agent = try Prompty.load([ + "kind": "prompt", + "name": "threaded", + "instructions": "system:\nYou are helpful.\n\n{{history}}\n\nuser:\n{{question}}", + "inputs": [ + ["name": "history", "kind": "thread"], + ["name": "question", "kind": "string"], + ], + ]) + + let messages = try await Pipeline.prepare( + agent, + inputs: [ + "history": [ + ["role": "user", "content": "first question"], + ["role": "assistant", "content": "first answer"], + ], + "question": "second question", + ] + ) + + let roles = messages.map { $0.role.rawValue } + XCTAssertEqual(roles, ["system", "user", "assistant", "user"]) + + let texts = messages.map { Self.text($0.parts) } + XCTAssertEqual(texts[1], "first question") + XCTAssertEqual(texts[2], "first answer") + XCTAssertEqual(texts[3], "second question") + } + + /// The nonce must not survive into the messages handed to a provider. + func testPrepareLeavesNoNonceResidue() async throws { + let agent = try Prompty.load([ + "kind": "prompt", + "name": "threaded-residue", + "instructions": "system:\nBe brief.\n\n{{history}}\n\nuser:\nhi", + "inputs": [["name": "history", "kind": "thread"]], + ]) + + let messages = try await Pipeline.prepare( + agent, + inputs: ["history": [["role": "user", "content": "prior"]]] + ) + + for message in messages { + XCTAssertFalse( + Self.text(message.parts).contains(Defaults.threadNoncePrefix), + "a nonce marker leaked into a prepared message") + } + } + + /// Concatenated text of a message's parts. + private static func text(_ parts: [ContentPart]) -> String { + parts.compactMap { part -> String? in + if case .textPart(let text) = part { return text.value } + return nil + }.joined() + } + + // MARK: - Registry publication + + /// Concurrent first use must never observe a half-installed registry. + /// + /// `registerDefaults` used to set its "already done" flag and release the + /// lock before installing the tables, so a racing caller could see the flag, + /// skip registration, and then fail to resolve a renderer that was not there + /// yet. + func testConcurrentRegisterDefaultsAlwaysResolves() async throws { + let registry = Registry() + + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<64 { + group.addTask { + registry.registerDefaults() + _ = try registry.renderer(for: Defaults.templateFormat) + _ = try registry.parser(for: Defaults.parser) + } + } + try await group.waitForAll() + } + } + + // MARK: - Expression evaluation + + /// Jinja expressions have to be evaluated, not pattern-matched. + /// + /// The previous renderer recognized only a handful of shapes and resolved + /// everything else to nothing, so a wrong condition rendered as an empty + /// string instead of failing. + func testRendererEvaluatesExpressions() async throws { + let cases: [(String, [String: Any], String)] = [ + ("{% if count > 2 %}many{% else %}few{% endif %}", ["count": 5], "many"), + ("{% if count > 2 %}many{% else %}few{% endif %}", ["count": 1], "few"), + ("{% if a and b %}both{% endif %}", ["a": true, "b": true], "both"), + ("{% if a and b %}both{% endif %}", ["a": true, "b": false], ""), + ("{% if not flag %}off{% endif %}", ["flag": false], "off"), + ("{% if 'x' in items %}found{% endif %}", ["items": ["w", "x"]], "found"), + ("{% if 'z' in items %}found{% endif %}", ["items": ["w", "x"]], ""), + ("{{ a + b }}", ["a": 2, "b": 3], "5"), + ("{{ name is defined }}", ["name": "jane"], "true"), + ("{{ missing is not defined }}", [:], "true"), + ("{{ items[1] }}", ["items": ["a", "b", "c"]], "b"), + ("{{ user.name }}", ["user": ["name": "jane"]], "jane"), + ] + + var run = VectorRun(stage: "expression") + for (template, inputs, expected) in cases { + run.started() + let agent = try Prompty.load([ + "kind": "prompt", "name": "expr", "instructions": template, + ]) + do { + let rendered = try await Pipeline.render(agent, inputs: inputs) + if rendered != expected { + run.fail( + template, "expected \(expected.debugDescription), got \(rendered.debugDescription)") + } + } catch { + run.fail(template, "\(error)") + } + } + run.assertClean() + } + + /// Syntax the runtime does not implement must be reported, not ignored. + func testRendererRejectsUnsupportedExpressions() async throws { + let agent = try Prompty.load([ + "kind": "prompt", + "name": "unsupported", + "instructions": "{{ value ** }}", + ]) + + do { + let rendered = try await Pipeline.render(agent, inputs: ["value": 2]) + XCTFail("expected a parse failure, rendered \(rendered.debugDescription)") + } catch { + // Expected: an unparseable expression is an error, not an empty string. + } + } + + // MARK: - Strict pre-render + + /// Strict mode must refuse a pre-render result it cannot understand. + /// + /// Accepting it as "no context" would quietly turn strict validation off, + /// which is the one thing strict mode is meant to guarantee. + func testStrictModeRejectsUnknownPreRenderResult() async throws { + let registry = Registry() + registry.registerDefaults() + registry.register(parser: StubParser(preRenderResult: "not a PreRenderResult"), for: "stub") + + let agent = try Prompty.load([ + "kind": "prompt", + "name": "strict-stub", + "instructions": "system:\nhi", + "template": ["format": ["kind": "jinja2"], "parser": ["kind": "stub"]], + ]) + + do { + _ = try await Pipeline.prepare(agent, inputs: [:], registry: registry) + XCTFail("expected strict mode to reject an unsupported pre-render result") + } catch { + // `InvokerError` is also a generated model type, so the error is matched + // by message rather than by a name that is ambiguous in test scope. + XCTAssertTrue("\(error)".contains("pre-render"), "unexpected message: \(error)") + } + } + + /// A parser that opts out of pre-render is still valid. + func testStrictModeAcceptsNilPreRenderResult() async throws { + let registry = Registry() + registry.registerDefaults() + registry.register(parser: StubParser(preRenderResult: nil), for: "stub-nil") + + let agent = try Prompty.load([ + "kind": "prompt", + "name": "strict-nil", + "instructions": "system:\nhi", + "template": ["format": ["kind": "jinja2"], "parser": ["kind": "stub-nil"]], + ]) + + let messages = try await Pipeline.prepare(agent, inputs: [:], registry: registry) + XCTAssertEqual(messages.count, 1) + } + + // MARK: - Structured output + + /// A provider that already returned JSON text must not be re-encoded. + /// + /// Serializing an existing JSON string produced a JSON string *literal*, so + /// decoding into the declared shape failed on every structured response. + func testStructuredCastAcceptsJSONText() throws { + struct Answer: Decodable, Equatable { + let city: String + let population: Int + } + + let decoded = try Structured.cast( + #"{"city":"Seattle","population":749256}"#, as: Answer.self) + XCTAssertEqual(decoded, Answer(city: "Seattle", population: 749_256)) + } + + /// Casting from an already-structured value keeps working. + func testStructuredCastAcceptsDictionary() throws { + struct Answer: Decodable, Equatable { + let city: String + let population: Int + } + + let decoded = try Structured.cast( + ["city": "Seattle", "population": 749_256] as [String: Any], as: Answer.self) + XCTAssertEqual(decoded, Answer(city: "Seattle", population: 749_256)) + } + + // MARK: - Streaming dispatch + + /// `run` has to notice the streaming option. + /// + /// Routing a streaming request through the buffered path hands raw SSE + /// frames to a JSON decoder. + func testIsStreamingReadsModelOptions() throws { + let streaming = try Prompty.load([ + "kind": "prompt", + "name": "streams", + "model": ["id": "gpt-4o-mini", "options": ["additionalProperties": ["stream": true]]], + ]) + XCTAssertTrue(Pipeline.isStreaming(streaming)) + + let buffered = try Prompty.load([ + "kind": "prompt", + "name": "buffered", + "model": ["id": "gpt-4o-mini", "options": ["additionalProperties": ["stream": false]]], + ]) + XCTAssertFalse(Pipeline.isStreaming(buffered)) + + let unset = try Prompty.load([ + "kind": "prompt", "name": "unset", "model": ["id": "gpt-4o-mini"], + ]) + XCTAssertFalse(Pipeline.isStreaming(unset)) + } +} +private struct StubParser: Parser { + let preRenderResult: Any? + + func preRender(template: String) throws -> Any? { preRenderResult } + + func parse(agent: Prompty, rendered: String, context: [String: Any]?) async throws -> [Message] { + [Message.user(text: rendered)] + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/RenderVectorTests.swift b/runtime/swift/prompty/Tests/PromptyTests/RenderVectorTests.swift new file mode 100644 index 000000000..6edd97aee --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/RenderVectorTests.swift @@ -0,0 +1,86 @@ +import Foundation + +import PromptyModel + +import XCTest + +/// Conformance against `spec/vectors/render/render_vectors.json`. +/// +/// Exercises both template engines plus the thread-nonce substitution that +/// keeps rich inputs out of the rendered text. +@testable import Prompty + +final class RenderVectorTests: XCTestCase { + + func testRenderVectors() async throws { + Registry.shared.registerDefaults() + var run = VectorRun(stage: "render") + + for vector in try Spec.vectors("render") { + let name = vector["name"] as? String ?? "" + run.started() + let input = vector["input"] as? [String: Any] ?? [:] + let expected = vector["expected"] as? [String: Any] ?? [:] + + let template = input["template"] as? String ?? "" + let engine = input["engine"] as? String ?? Defaults.templateFormat + let inputs = input["inputs"] as? [String: Any] ?? [:] + + do { + let agent = try Self.agent(name: name, template: template, engine: engine, inputs: inputs) + let rendered = try await Pipeline.render(agent, inputs: inputs) + + if let expectedText = expected["rendered"] as? String { + guard rendered == expectedText else { + run.fail( + name, + "rendered mismatch:\n actual: \(Spec.describe(rendered))\n expected: \(Spec.describe(expectedText))" + ) + continue + } + } + + if let pattern = expected["nonce_pattern"] as? String { + let regex = try NSRegularExpression(pattern: pattern) + let range = NSRange(rendered.startIndex..., in: rendered) + guard regex.firstMatch(in: rendered, range: range) != nil else { + run.fail( + name, "rendered text does not match \(pattern):\n actual: \(Spec.describe(rendered))" + ) + continue + } + } + } catch { + run.fail(name, "\(error)") + } + } + + run.assertClean() + } + + /// Build the synthetic agent a render vector implies. + /// + /// Rich-kind substitution is driven by the *declared* input property, so any + /// input the vector marks `_kind: thread` must appear in `inputs` as a + /// thread-kind property. + private static func agent( + name: String, template: String, engine: String, inputs: [String: Any] + ) throws -> Prompty { + var properties: [[String: Any]] = [] + for (key, value) in inputs.sorted(by: { $0.key < $1.key }) { + let kind = (value as? [String: Any])?["_kind"] as? String ?? "string" + properties.append(["name": key, "kind": kind]) + } + + return try Prompty.load([ + "kind": "prompt", + "name": name, + "instructions": template, + "inputs": properties, + "template": [ + "format": ["kind": engine], + "parser": ["kind": Defaults.parser], + ], + ]) + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/ReplayVectorTests.swift b/runtime/swift/prompty/Tests/PromptyTests/ReplayVectorTests.swift new file mode 100644 index 000000000..e35f521dd --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/ReplayVectorTests.swift @@ -0,0 +1,223 @@ +import Foundation + +import PromptyModel + +import XCTest + +/// Conformance against `spec/vectors/harness/replay_vectors.json`. +/// +/// Runs the real `ReferenceTurnRunner` against a real on-disk JSONL journal, +/// then normalizes that journal to the shared string form and compares it to +/// the golden sequence. Because the journal is read back from disk rather than +/// from an in-memory sink, this exercises durability as well as ordering. +@testable import Prompty + +/// Deterministic monotonic counter for id generation. +final class ReplayVectorTests: XCTestCase { + + func testReplayVectors() async throws { + var run = VectorRun(stage: "replay") + + let file = try Spec.vectorObject("harness/replay_vectors.json") + XCTAssertEqual(file["version"] as? Int, 1, "unexpected replay vector version") + + let sessionId = file["sessionId"] as? String ?? "session-1" + let turnId = file["turnId"] as? String ?? "turn-1" + let clock = file["clock"] as? String ?? "2026-06-28T00:00:00Z" + + for scenario in file["scenarios"] as? [[String: Any]] ?? [] { + let name = scenario["name"] as? String ?? "" + + await run.checkAsync(name) { + let actual = try await Self.runScenario( + scenario, sessionId: sessionId, turnId: turnId, clock: clock) + let expected = scenario["expected"] as? [String] ?? [] + try expect(!expected.isEmpty, "vector '\(name)' declares no expected journal") + + try expectEqual(actual, expected, "journal for '\(name)'") + + // The same sequence must also verify through the shipped verifier, so + // a runtime can prove replay equivalence without string comparison. + try Self.verifyThroughVerifier(expected: expected, actual: actual) + } + } + + run.assertClean() + } + + // MARK: - Scenario execution + + private static func runScenario( + _ scenario: [String: Any], sessionId: String, turnId: String, clock: String + ) async throws -> [String] { + let name = scenario["name"] as? String ?? "" + let journalPath = NSTemporaryDirectory() + "prompty-replay-\(name)-\(UUID().uuidString).jsonl" + defer { try? FileManager.default.removeItem(atPath: journalPath) } + + let journal = JsonlEventJournalWriter(path: journalPath) + + // Ids must be deterministic for the journal to be byte-comparable. + let counter = Counter() + let runner = ReferenceTurnRunner( + eventSink: CollectingEventSink(), + journal: journal, + checkpointStore: InMemoryCheckpointStore(), + permissionResolver: name == "permission_denied" + ? DenyAllPermissionResolver() : AllowAllPermissionResolver(), + hostToolExecutor: Self.toolExecutor(), + invokeModel: Self.model(for: name), + now: { clock }, + nextId: { prefix in "\(prefix)-\(counter.next())" } + ) + + var request = RunTurnRequest(sessionId: sessionId, turnId: turnId) + request.inputs = scenario["inputs"] as? [String: Any] ?? ["name": "Ada"] + if let maxIterations = scenario["maxIterations"] as? Int { + var options = TurnOptions() + options.maxIterations = Int32(maxIterations) + request.options = options + } + + _ = try await runner.run(request) + + return normalize(try JsonlEventJournalWriter.readRecords(path: journalPath)) + } + + /// Mirrors `model_for_scenario` in the Rust reference runner. + private static func model(for scenario: String) -> ReferenceTurnRunner.ModelCallback { + { request in + if scenario == "no_tool" { + var response = TurnModelResponse() + let name = (request.inputs?["name"] as? String) ?? "" + response.output = ["text": "hello \(name)"] + response.checkpointState = ["stable": true] + return response + } + + if request.iteration == 0 { + var toolRequest = HostToolRequest( + toolName: scenario == "tool_failure" ? "fail" : "add") + toolRequest.requestId = "exec-1" + toolRequest.toolCallId = "call-1" + toolRequest.arguments = ["a": 2, "b": 3] + + var response = TurnModelResponse() + response.toolRequests = [toolRequest] + return response + } + + var response = TurnModelResponse() + let first = request.toolResults?.first + response.output = [ + "toolResult": first?.result as Any, + "errorKind": first?.errorKind as Any, + ] + return response + } + } + + private static func toolExecutor() -> FunctionHostToolExecutor { + FunctionHostToolExecutor(handlers: [ + "add": { arguments in + let a = (arguments["a"] as? Int) ?? 0 + let b = (arguments["b"] as? Int) ?? 0 + return ["sum": a + b] + }, + "fail": { _ in + throw InvokerError.execution("tool unavailable") + }, + ]) + } + + // MARK: - Journal normalization + + /// Mirrors `normalize_journal` in the Rust reference runner so both runtimes + /// compare against the same golden strings. + private static func normalize(_ records: [[String: Any]]) -> [String] { + records.map { record in + let kind = record["kind"] as? String ?? "" + + if kind == "summary" { + let summary = record["summary"] as? [String: Any] ?? [:] + return [ + "summary", + summary["sessionId"] as? String ?? "", + summary["status"] as? String ?? "", + "turns=\(summary["turns"] ?? 0)", + "checkpoints=\(summary["checkpoints"] ?? 0)", + ].joined(separator: ":") + } + + let event = record["event"] as? [String: Any] ?? [:] + let type = event["type"] as? String ?? "" + let payload = event["payload"] as? [String: Any] ?? [:] + + if kind == "session" { + var parts = [ + "session", type, event["sessionId"] as? String ?? "", + event["turnId"] as? String ?? "", + ] + if type == "session_end" { parts.append(payload["status"] as? String ?? "") } + return parts.joined(separator: ":") + } + + let iteration = "\(event["iteration"] ?? 0)" + var parts = ["turn", type, iteration] + + switch type { + case "permission_requested": + parts.append(payload["requestId"] as? String ?? "") + case "permission_completed": + parts.append("\(payload["approved"] as? Bool ?? false)") + case "tool_execution_start": + parts.append(payload["toolName"] as? String ?? "") + case "tool_execution_complete", "tool_result": + parts.append(payload["toolName"] as? String ?? "") + parts.append("\(payload["success"] as? Bool ?? false)") + if let errorKind = payload["errorKind"] as? String { parts.append(errorKind) } + case "error": + parts.append(payload["errorKind"] as? String ?? "") + case "turn_end": + parts.append(payload["status"] as? String ?? "") + default: + break + } + return parts.joined(separator: ":") + } + } + + // MARK: - Verifier cross-check + + private static func verifyThroughVerifier(expected: [String], actual: [String]) throws { + let request = ReplayVerificationRequest( + expected: expected.map(record), actual: actual.map(record)) + let result = try ReferenceReplayVerifier().verify(request) + + try expect( + result.status == .passed, + "replay verifier reported \(result.status.rawValue): \(result.mismatches ?? [])") + try expectEqual(Int(result.expectedCount), expected.count, "verifier expectedCount") + try expectEqual(Int(result.actualCount), actual.count, "verifier actualCount") + } + + /// Project a normalized string back onto a journal record so the verifier + /// compares the same information the golden vectors pin down. + private static func record(_ normalized: String) -> ReplayJournalRecord { + var record = ReplayJournalRecord() + let parts = normalized.split(separator: ":", maxSplits: 1).map(String.init) + record.kind = (try? ReplayRecordKind.parse(parts.first ?? "session")) ?? .session + record.type = normalized + return record + } +} +private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func next() -> Int { + lock.lock() + defer { lock.unlock() } + value += 1 + return value + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/SpecVectors.swift b/runtime/swift/prompty/Tests/PromptyTests/SpecVectors.swift new file mode 100644 index 000000000..5356c98aa --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/SpecVectors.swift @@ -0,0 +1,205 @@ +import Foundation + +import XCTest + +/// Shared spec-vector plumbing. +/// +/// Every Prompty runtime is validated against the same JSON vectors under +/// `spec/vectors/`, so conformance is comparable across languages. This file +/// locates that directory relative to the test source and provides the +/// order-insensitive JSON comparison the vectors are written against. +@testable import Prompty + +/// Collects per-vector failures so one run reports every mismatch at once. +/// +/// Failing fast on the first vector hides how much of a stage is broken, which +/// is exactly the signal a conformance suite should give. + +/// A vector assertion failed. + +/// Assert a condition inside a vector body. + +/// Assert deep JSON equality inside a vector body. +enum Spec { + + /// The repository's `spec/` directory. + /// + /// Resolved from `#filePath` rather than the working directory so the tests + /// run identically from an IDE, `swift test`, and CI. + static let root: URL = { + // .../runtime/swift/prompty/Tests/PromptyTests/SpecVectors.swift + var url = URL(fileURLWithPath: #filePath) + for _ in 0..<6 { url = url.deletingLastPathComponent() } + return url.appendingPathComponent("spec") + }() + + static var fixtures: URL { root.appendingPathComponent("fixtures") } + + /// Read one stage's vector file. + static func vectors(_ stage: String, file: String? = nil) throws -> [[String: Any]] { + let name = file ?? "\(stage)_vectors.json" + let url = + root + .appendingPathComponent("vectors") + .appendingPathComponent(stage) + .appendingPathComponent(name) + + let data = try Data(contentsOf: url) + guard let array = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + throw SpecError.malformed("\(url.path) is not a JSON array of objects") + } + return array + } + + /// Read a vector file whose root is an object rather than an array. + static func vectorObject(_ relativePath: String) throws -> [String: Any] { + var url = root.appendingPathComponent("vectors") + for component in relativePath.split(separator: "/") { + url = url.appendingPathComponent(String(component)) + } + + let data = try Data(contentsOf: url) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw SpecError.malformed("\(url.path) is not a JSON object") + } + return object + } + + enum SpecError: Error, CustomStringConvertible { + case malformed(String) + var description: String { + switch self { + case .malformed(let detail): return detail + } + } + } + + // MARK: - Comparison + + /// Deep JSON equality that ignores object key order and treats numerically + /// equal integers and doubles as equal. + static func equal(_ actual: Any?, _ expected: Any?) -> Bool { + switch (normalize(actual), normalize(expected)) { + case (nil, nil): + return true + + case (let a as [String: Any], let b as [String: Any]): + guard a.count == b.count else { return false } + return a.allSatisfy { key, value in + b.keys.contains(key) && equal(value, b[key]) + } + + case (let a as [Any], let b as [Any]): + guard a.count == b.count else { return false } + return zip(a, b).allSatisfy { equal($0, $1) } + + case (let a as String, let b as String): + return a == b + + case (let a as Bool, let b as Bool): + return a == b + + case (let a as NSNumber, let b as NSNumber): + // Model options are 32-bit floats in the generated model, so a vector's + // 0.7 arrives as 0.699999988. Compare with a float-scale tolerance. + let scale = max(abs(a.doubleValue), abs(b.doubleValue), 1.0) + return abs(a.doubleValue - b.doubleValue) <= scale * 1e-6 + + default: + return false + } + } + + /// Strip `NSNull` so a JSON null and an absent Swift value compare equal. + private static func normalize(_ value: Any?) -> Any? { + guard let value, !(value is NSNull) else { return nil } + return value + } + + /// A readable rendering of a value for assertion messages. + static func describe(_ value: Any?) -> String { + guard let value, !(value is NSNull) else { return "null" } + if JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data( + withJSONObject: value, options: [.sortedKeys, .prettyPrinted]), + let text = String(data: data, encoding: .utf8) + { + return text + } + return String(describing: value) + } +} +struct VectorRun { + let stage: String + private(set) var failures: [String] = [] + private(set) var ran = 0 + private(set) var skipped = 0 + + init(stage: String) { self.stage = stage } + + /// Run one vector, recording any thrown error or assertion as a failure. + mutating func check(_ name: String, _ body: () throws -> Void) { + ran += 1 + do { + try body() + } catch { + failures.append("[\(name)] \(error)") + } + } + + /// Async counterpart of ``check(_:_:)`` for stages that await. + mutating func checkAsync(_ name: String, _ body: () async throws -> Void) async { + ran += 1 + do { + try await body() + } catch { + failures.append("[\(name)] \(error)") + } + } + + mutating func skip() { skipped += 1 } + + /// Record that a vector is about to run. + /// + /// Suites that handle their own errors call this so the run still knows how + /// much was actually exercised — otherwise a suite that silently matched + /// zero vectors would be indistinguishable from a passing one. + mutating func started() { ran += 1 } + + mutating func fail(_ name: String, _ message: String) { + failures.append("[\(name)] \(message)") + } + + /// Assert every vector passed. + /// + /// A run that executed nothing is treated as a failure: a conformance suite + /// that silently matches zero vectors is indistinguishable from a passing + /// one, and that is the most dangerous way for this harness to break. + func assertClean(file: StaticString = #filePath, line: UInt = #line) { + if ran == 0 && skipped == 0 { + XCTFail( + "no \(stage) vectors ran — the vector file was empty or misread", file: file, line: line) + return + } + guard !failures.isEmpty else { return } + XCTFail( + "\(failures.count)/\(ran) \(stage) vectors failed:\n\n" + failures.joined(separator: "\n\n"), + file: file, + line: line + ) + } +} +struct VectorFailure: Error, CustomStringConvertible { + let description: String + init(_ message: String) { description = message } +} +func expect(_ condition: Bool, _ message: @autoclosure () -> String) throws { + guard condition else { throw VectorFailure(message()) } +} +func expectEqual(_ actual: Any?, _ expected: Any?, _ label: String) throws { + guard Spec.equal(actual, expected) else { + throw VectorFailure( + "\(label) mismatch:\n actual: \(Spec.describe(actual))\n expected: \(Spec.describe(expected))" + ) + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/ToolBindingTests.swift b/runtime/swift/prompty/Tests/PromptyTests/ToolBindingTests.swift new file mode 100644 index 000000000..d837ec147 --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/ToolBindingTests.swift @@ -0,0 +1,487 @@ +import Foundation + +import PromptyModel + +import XCTest + +/// Tool binding conformance — the *executable* half of the bindings contract. +/// +/// Bindings are two coupled rules, and testing either alone proves nothing: +/// +/// §2.9.1.1 bound parameters are **stripped** from the schema sent to the model +/// §2.9.1.2 bound parameters are **injected** into the arguments before the tool runs +/// §2.9.1.3 an injected value **overrides** whatever the model produced +/// +/// Strip-without-inject silently drops an argument: the model never sees the +/// parameter, so it never supplies one, and the tool is invoked short. These +/// tests pin both halves together, and pin the load side against both declared +/// binding shapes, so a regression in either surfaces here rather than at a +/// provider call. +/// +/// The injection cases mirror `spec/vectors/agent/agent_vectors.json` +/// (`bindings_injected`) and the reference implementation in +/// `runtime/rust/prompty/src/tool_dispatch.rs::resolve_bindings`. +@testable import Prompty + +final class ToolBindingTests: XCTestCase { + + // MARK: - Fixtures + + /// The canonical `bindings_injected` tool: one bound parameter (`unit`) fed + /// from a parent input (`preferred_unit`), one free parameter (`city`). + private func weatherAgent( + bindings: Any = ["unit": ["input": "preferred_unit"]] + ) throws -> Prompty { + try Prompty.load([ + "kind": "prompt", + "name": "weather", + "model": ["id": "gpt-4o-mini", "apiType": "chat"], + "tools": [ + [ + "name": "get_weather", + "kind": "function", + "description": "Get the current weather for a city", + "parameters": [ + ["name": "city", "kind": "string", "required": true], + ["name": "unit", "kind": "string", "required": false], + ], + "bindings": bindings, + ] + ], + "instructions": "user:\nWhat is the weather?", + ]) + } + + private func tool(_ agent: Prompty) throws -> Tool { + let tool = try XCTUnwrap(agent.tools?.first) + return tool + } + + // MARK: - Load: both declared shapes + + /// Map form — the key supplies the binding name. This is the shape used by + /// `spec/fixtures/tools_function.prompty` and the `bindings_injected` vector. + func testBindingsLoadFromMapForm() throws { + let agent = try weatherAgent(bindings: ["unit": ["input": "preferred_unit"]]) + let bindings = try tool(agent).bindings + + XCTAssertEqual(bindings.count, 1) + XCTAssertEqual(bindings.first?.name, "unit") + XCTAssertEqual(bindings.first?.input, "preferred_unit") + } + + /// List form — the name is already inline. Must normalize identically. + func testBindingsLoadFromListForm() throws { + let agent = try weatherAgent(bindings: [["name": "unit", "input": "preferred_unit"]]) + let bindings = try tool(agent).bindings + + XCTAssertEqual(bindings.count, 1) + XCTAssertEqual(bindings.first?.name, "unit") + XCTAssertEqual(bindings.first?.input, "preferred_unit") + } + + /// Map form with a bare scalar value coerces the scalar into `input`, so the + /// shorthand `unit: preferred_unit` means the same as `unit: {input: ...}`. + func testBindingsLoadFromMapFormScalarShorthand() throws { + let agent = try weatherAgent(bindings: ["unit": "preferred_unit"]) + let bindings = try tool(agent).bindings + + XCTAssertEqual(bindings.count, 1) + XCTAssertEqual(bindings.first?.name, "unit") + XCTAssertEqual(bindings.first?.input, "preferred_unit") + } + + /// Both declared shapes must survive a save/reload cycle unchanged — that is + /// what makes them interchangeable rather than merely both-accepted. + func testBindingsSurviveSaveAndReload() throws { + for declared in [ + ["unit": ["input": "preferred_unit"]] as Any, + [["name": "unit", "input": "preferred_unit"]] as Any, + ["unit": "preferred_unit"] as Any, + ] { + let agent = try weatherAgent(bindings: declared) + let reloaded = try Prompty.load(try agent.save()) + let bindings = try tool(reloaded).bindings + + XCTAssertEqual(bindings.count, 1, "declared as \(declared)") + XCTAssertEqual(bindings.first?.name, "unit", "declared as \(declared)") + XCTAssertEqual(bindings.first?.input, "preferred_unit", "declared as \(declared)") + } + } + + // MARK: - Strip half (§2.9.1.1) + + /// A bound parameter must not reach the model. `boundParameterNames` is what + /// wire conversion filters on, so it is the seam worth pinning. + func testBoundParametersAreReportedForStripping() throws { + let agent = try weatherAgent() + XCTAssertEqual(try tool(agent).boundParameterNames, ["unit"]) + } + + // MARK: - Inject half (§2.9.1.2) + + /// The vector case: the model omits the bound parameter, and injection + /// supplies it from the parent inputs. + func testInjectsBoundValueOmittedByModel() throws { + let agent = try weatherAgent() + + let merged = Pipeline.applyBindings( + agent, + toolName: "get_weather", + arguments: ["city": "Paris"], + inputs: ["preferred_unit": "celsius"] + ) + + XCTAssertEqual(merged["city"] as? String, "Paris") + XCTAssertEqual(merged["unit"] as? String, "celsius") + } + + /// §2.9.1.3 — a binding is authoritative. A model that guesses a value for a + /// bound parameter must not be able to override the bound one. + func testBindingOverridesModelSuppliedValue() throws { + let agent = try weatherAgent() + + let merged = Pipeline.applyBindings( + agent, + toolName: "get_weather", + arguments: ["city": "Paris", "unit": "fahrenheit"], + inputs: ["preferred_unit": "celsius"] + ) + + XCTAssertEqual(merged["unit"] as? String, "celsius") + } + + /// Bindings carry whatever the input holds, not just strings — a bound + /// parameter is a value pipe, so non-string inputs must pass through intact. + func testInjectsNonStringInputValues() throws { + for (value, check) in [ + (42 as Any, { (v: Any?) in (v as? Int) == 42 }), + (1.5 as Any, { (v: Any?) in (v as? Double) == 1.5 }), + (true as Any, { (v: Any?) in (v as? Bool) == true }), + (["a", "b"] as Any, { (v: Any?) in (v as? [String]) == ["a", "b"] }), + ] { + let agent = try weatherAgent() + let merged = Pipeline.applyBindings( + agent, + toolName: "get_weather", + arguments: [:], + inputs: ["preferred_unit": value] + ) + XCTAssertTrue(check(merged["unit"]), "binding dropped or coerced \(value)") + } + } + + /// An absent input is skipped rather than injected as null, so a partially + /// supplied input set degrades to the model's own arguments. + func testMissingInputIsSkipped() throws { + let agent = try weatherAgent() + + let merged = Pipeline.applyBindings( + agent, + toolName: "get_weather", + arguments: ["city": "Paris"], + inputs: [:] + ) + + XCTAssertEqual(merged as? [String: String], ["city": "Paris"]) + XCTAssertNil(merged["unit"]) + } + + /// Multiple bindings all resolve independently. + func testMultipleBindingsAllInject() throws { + let agent = try weatherAgent(bindings: [ + "unit": ["input": "preferred_unit"], + "locale": ["input": "user_locale"], + ]) + + let merged = Pipeline.applyBindings( + agent, + toolName: "get_weather", + arguments: ["city": "Paris"], + inputs: ["preferred_unit": "celsius", "user_locale": "fr-FR"] + ) + + XCTAssertEqual(merged["unit"] as? String, "celsius") + XCTAssertEqual(merged["locale"] as? String, "fr-FR") + } + + // MARK: - Pass-through cases + + /// Injection is called on every tool call, so an unbound tool must be cheap + /// and lossless rather than an error. + func testToolWithoutBindingsPassesThrough() throws { + let agent = try weatherAgent(bindings: [] as [Any]) + + let merged = Pipeline.applyBindings( + agent, + toolName: "get_weather", + arguments: ["city": "Paris"], + inputs: ["preferred_unit": "celsius"] + ) + + XCTAssertEqual(merged as? [String: String], ["city": "Paris"]) + } + + /// A tool call naming a tool the prompt never declared passes through, so a + /// host dispatching mixed local and prompt-declared tools is unaffected. + func testUnknownToolPassesThrough() throws { + let agent = try weatherAgent() + + let merged = Pipeline.applyBindings( + agent, + toolName: "not_declared", + arguments: ["city": "Paris"], + inputs: ["preferred_unit": "celsius"] + ) + + XCTAssertEqual(merged as? [String: String], ["city": "Paris"]) + } + + /// A prompt with no tools at all passes through. + func testAgentWithoutToolsPassesThrough() throws { + let agent = try Prompty.load([ + "kind": "prompt", "name": "bare", "model": ["id": "gpt-4o-mini"], + "instructions": "user:\nhi", + ]) + + let merged = Pipeline.applyBindings( + agent, toolName: "get_weather", arguments: ["city": "Paris"], inputs: ["x": "y"]) + + XCTAssertEqual(merged as? [String: String], ["city": "Paris"]) + } + + // MARK: - ToolCall convenience + + /// The host-facing entry point decodes the provider's JSON argument string + /// and applies bindings in one step. + func testBoundArgumentsDecodesToolCallJSON() throws { + let agent = try weatherAgent() + let call = ToolCall(id: "call_1", name: "get_weather", arguments: "{\"city\":\"Paris\"}") + + let merged = Pipeline.boundArguments(agent, call: call, inputs: ["preferred_unit": "celsius"]) + + XCTAssertEqual(merged["city"] as? String, "Paris") + XCTAssertEqual(merged["unit"] as? String, "celsius") + } + + /// The free-function surface mirrors the namespaced one. + func testTopLevelBoundArgumentsMatchesPipeline() throws { + let agent = try weatherAgent() + let call = ToolCall(id: "call_1", name: "get_weather", arguments: "{\"city\":\"Paris\"}") + + let merged = boundArguments(agent, call: call, inputs: ["preferred_unit": "celsius"]) + + XCTAssertEqual(merged["unit"] as? String, "celsius") + } + + // MARK: - Shared vector + /// Rewrite a vector tool's `parameters` from the non-canonical + /// `{properties: [...]}` wrapper to the shape the schema actually declares. + /// + /// `spec/spec.md` §2.9.1's prose example writes `parameters: {properties: [...]}`, + /// and `agent_vectors.json` follows it. The canonical TypeSpec disagrees: + /// `FunctionTool.parameters` is `Properties`, and + /// `schema/model/core/properties.tsp` defines + /// `Properties = Record | Named[]` — a name-keyed map or a + /// list, with no wrapper. `spec/fixtures/tools_function.prompty` uses the list + /// form, and so does every other fixture. + /// + /// Rust does not notice the divergence because its generated `FunctionTool` + /// carries no `parameters` field at all, so the wrapper is silently dropped + /// during load. Swift's generated model does carry the field, so the wrapper + /// is a hard load error — which is how this surfaced. + /// + /// Bindings do not depend on `parameters`, so normalizing here keeps the + /// binding contract executable without editing the shared vector. When the + /// vector is corrected upstream, ``testVectorParametersStillUseNonCanonicalWrapper`` + /// goes red and this helper can be deleted. + private func canonicalizeParameters(_ tool: [String: Any]) -> [String: Any] { + guard let params = tool["parameters"] as? [String: Any], + let unwrapped = params["properties"] + else { return tool } + + var copy = tool + copy["parameters"] = unwrapped + return copy + } + + /// Tripwire for the divergence ``canonicalizeParameters`` works around. + /// + /// If this fails, the shared vector has been corrected to the canonical + /// `Properties` shape — delete `canonicalizeParameters` and this test, and + /// pass the vector's tools through verbatim. + func testVectorParametersStillUseNonCanonicalWrapper() throws { + let vectors = try Spec.vectors("agent") + let vector = try XCTUnwrap(vectors.first { $0["name"] as? String == "bindings_injected" }) + let tools = try XCTUnwrap((vector["input"] as? [String: Any])?["tools"] as? [[String: Any]]) + let params = try XCTUnwrap(tools.first?["parameters"] as? [String: Any]) + + XCTAssertNotNil( + params["properties"], + """ + agent_vectors.json bindings_injected now declares canonical `parameters`. \ + Remove canonicalizeParameters() and this tripwire, and load the vector's \ + tools verbatim. + """) + } + + /// Execute the canonical `bindings_injected` case from + /// `spec/vectors/agent/agent_vectors.json` rather than restating it. + /// + /// This is the cross-runtime contract Rust already runs. Driving the real + /// vector means a change to the shared expectation reaches Swift instead of + /// silently diverging from a hand-copied duplicate. + func testSharedBindingsInjectedVector() throws { + let vectors = try Spec.vectors("agent") + let vector = try XCTUnwrap( + vectors.first { $0["name"] as? String == "bindings_injected" }, + "bindings_injected vector missing from agent_vectors.json") + + let input = try XCTUnwrap(vector["input"] as? [String: Any]) + let tools = try XCTUnwrap(input["tools"] as? [[String: Any]]) + let parentInputs = try XCTUnwrap(input["parent_inputs"] as? [String: Any]) + + // Build a prompt carrying the vector's own tool declarations. + let agent = try Prompty.load([ + "kind": "prompt", + "name": "bindings-vector", + "model": ["id": "gpt-4o-mini", "apiType": "chat"], + "tools": tools.map(canonicalizeParameters), + "instructions": "user:\nWhat is the weather?", + ]) + + let sequence = try XCTUnwrap(vector["sequence"] as? [[String: Any]]) + var assertions = 0 + + for turn in sequence { + guard let calls = turn["expected_tool_calls"] as? [[String: Any]], + let expectedArgs = turn["expected_execution_args"] as? [String: Any] + else { continue } + + for call in calls { + let name = try XCTUnwrap(call["name"] as? String) + // The vector records the arguments the LLM produced; execution args are + // what the tool must actually receive. + let llmArgs = call["arguments"] as? [String: Any] ?? [:] + let expected = try XCTUnwrap(expectedArgs[name] as? [String: Any]) + + let merged = Pipeline.applyBindings( + agent, toolName: name, arguments: llmArgs, inputs: parentInputs) + + XCTAssertTrue( + Spec.equal(merged, expected), + "\(name) execution args: got \(Spec.describe(merged)), expected \(Spec.describe(expected))" + ) + assertions += 1 + } + } + + XCTAssertGreaterThan(assertions, 0, "vector produced no execution-args assertions") + } + + // MARK: - The recorded call is never rewritten + + /// A bound value must not travel back to the model. + /// + /// Bindings hide a parameter on purpose — the spec's own example binds an + /// environment-supplied user id. Injecting into the *recorded* call would put + /// that value into the assistant tool-call history replayed on the next + /// round, handing the model exactly what the binding withheld. + func testRecordedToolCallIsNotRewritten() throws { + let agent = try weatherAgent() + let raw: Any = [["id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}"]] + + let calls = Pipeline.toolCalls(in: raw) + _ = Pipeline.boundArguments(agent, call: calls[0], inputs: ["preferred_unit": "celsius"]) + + XCTAssertEqual(calls[0].arguments, "{\"city\":\"Paris\"}") + XCTAssertNil(calls[0].argumentValues["unit"]) + } + + /// A provider payload that is not an argument object is passed through rather + /// than replaced by one containing only the bound values. + /// + /// The security property under test is that no *fabricated* object reaches + /// the tool: injecting into a payload that was never an argument object would + /// hand the handler a dictionary whose only contents are the bound values, + /// which is the opposite of "the tool receives what the model asked for, plus + /// the binding". + /// + /// Note a known divergence from the Rust reference: `dispatch_tool` + /// (`tool_dispatch.rs` ~L293-309) returns an error string to the model for + /// malformed JSON and preserves valid non-object payloads verbatim. Swift's + /// ``ToolCall/argumentValues`` flattens every non-object to `[:]`, which + /// predates bindings and is the host-dispatch contract the runtime already + /// exposes. Bindings deliberately do not change it — they only decline to + /// inject. Tightening `argumentValues` is tracked separately. + func testNonObjectArgumentsAreNotReplaced() throws { + let agent = try weatherAgent() + + for payload in ["[1,2,3]", "\"just a string\"", "42", "{not json"] { + let call = ToolCall(id: "call_1", name: "get_weather", arguments: payload) + let merged = Pipeline.boundArguments(agent, call: call, inputs: ["preferred_unit": "celsius"]) + + XCTAssertNil(merged["unit"], "bindings were injected into non-object payload \(payload)") + // Identical to the un-bound decode: bindings changed nothing at all. + XCTAssertEqual( + merged.count, call.argumentValues.count, + "payload \(payload) was rewritten rather than passed through") + XCTAssertTrue( + merged.isEmpty, + "payload \(payload) produced a fabricated argument object: \(merged)") + } + } + + /// An empty payload is the no-argument call, which is precisely when every + /// parameter a tool needs may be a bound one. + func testEmptyArgumentsStillReceiveBindings() throws { + let agent = try weatherAgent() + + for payload in ["", " ", "{}"] { + let call = ToolCall(id: "call_1", name: "get_weather", arguments: payload) + let merged = Pipeline.boundArguments(agent, call: call, inputs: ["preferred_unit": "celsius"]) + + XCTAssertEqual(merged["unit"] as? String, "celsius", "payload \(payload)") + } + } + + /// An unnamed binding strips nothing, so it must also inject nothing — + /// otherwise a parameter would vanish from the schema and never come back. + func testUnnamedBindingNeitherStripsNorInjects() throws { + let agent = try weatherAgent(bindings: [["input": "preferred_unit"]]) + + XCTAssertTrue(try tool(agent).boundParameterNames.isEmpty) + + let merged = Pipeline.applyBindings( + agent, toolName: "get_weather", arguments: ["city": "Paris"], + inputs: ["preferred_unit": "celsius"]) + XCTAssertEqual(merged as? [String: String], ["city": "Paris"]) + } + + // MARK: - End to end + + /// The whole contract on the shipped fixture: the fixture declares map-form + /// bindings, the bound parameter is withheld from the model, and the value + /// the model never saw is restored from the prompt's own inputs. + func testFixtureStripsThenInjects() throws { + let path = Spec.fixtures.appendingPathComponent("tools_function.prompty").path + let agent = try Loader.load(path: path) + let tool = try XCTUnwrap(agent.tools?.first) + + // Declared as a map in the fixture, loaded as a named binding. + XCTAssertEqual(tool.bindings.map(\.name), ["unit"]) + XCTAssertEqual(tool.bindings.map(\.input), ["preferred_unit"]) + + // Stripped from what the model is shown... + XCTAssertEqual(tool.boundParameterNames, ["unit"]) + + // ...and restored before the tool runs. + let merged = Pipeline.applyBindings( + agent, + toolName: "get_weather", + arguments: ["location": "Paris"], + inputs: ["preferred_unit": "celsius"] + ) + XCTAssertEqual(merged["unit"] as? String, "celsius") + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/WildcardPreservationTests.swift b/runtime/swift/prompty/Tests/PromptyTests/WildcardPreservationTests.swift new file mode 100644 index 000000000..fb04c8da8 --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/WildcardPreservationTests.swift @@ -0,0 +1,180 @@ +import XCTest + +@testable import Prompty +@testable import PromptyModel + +/// Characterization coverage for the two *different* strategies the generated +/// model uses to absorb an unrecognised discriminator, and what each one costs. +/// +/// This matters beyond Swift. `Connection` is declared as a closed union in +/// `schema/model/connection/connection.tsp`, and the open question is how to +/// open it. Two shapes get proposed: declare a typed wildcard subtype (the +/// shape `CustomTool { kind: "*" }` already uses for `Tool`), or open the +/// discriminator itself so the raw payload survives. They are frequently +/// treated as interchangeable. These tests measure that, as currently emitted, +/// they are not. +/// +/// - `Tool` absorbs `kind: "vendor_specific"` into the typed `CustomTool` +/// subtype. `CustomTool` declares only named fields and no raw catch-all, so +/// unrecognised top-level fields are dropped on save. +/// - `Connection` absorbs an unrecognised kind into `.unknown([String: Any])`, +/// which is a raw dictionary passthrough, so every top-level field survives — +/// including the original discriminator. +/// +/// Scope carefully. What is measured is that *a wildcard subtype declaring only +/// named fields* loses unknown fields; it does not follow that every possible +/// `Connection` wildcard would, since one could declare raw catch-all storage. +/// The transferable conclusion is that the choice is not neutral and the +/// declared shape decides it. Note also that `spec.md` §2.3 (lines 246-247) +/// permits unknown properties to be "preserved in `metadata` **or ignored**", +/// so dropping them is not by itself a spec violation — it is a strictly weaker +/// forward-compatibility guarantee than the raw-passthrough path already +/// provides here. +/// +/// These are characterization assertions: they pin current behaviour so that a +/// change is noticed, not behaviour the spec has blessed. `spec/spec.md` §2.5 +/// does not currently state a requirement for unrecognised connection kinds +/// (see ``ConnectionRoundTripTests``), and no shared vector covers unknown +/// top-level field preservation on either type. If the wildcard-subtype path is +/// later fixed to preserve unknown fields, ``testCustomToolDropsUnknownTopLevelFields`` +/// is expected to fail — treat that as the signal to re-derive the contract, +/// not as a regression to paper over. +final class WildcardPreservationTests: XCTestCase { + + // MARK: - Tool: typed wildcard subtype + + /// A typed wildcard subtype does NOT round-trip arbitrary top-level fields. + /// + /// Every key here is legal JSON on a forward-compatible tool entry, and only + /// the ones `CustomTool` declares come back. + func testCustomToolDropsUnknownTopLevelFields() throws { + let input: [String: Any] = [ + "kind": "vendor_specific", + "name": "vendorThing", + "description": "a vendor tool", + "options": ["a": 1], + "extra": "keepme", + "listy": [1, 2, 3], + "nested": ["deep": ["x": 1]], + ] + + let saved = try Tool.load(input, context: LoadContext()).save(SaveContext()) + + // Declared fields survive. + XCTAssertEqual(saved["kind"] as? String, "vendor_specific") + XCTAssertEqual(saved["name"] as? String, "vendorThing") + XCTAssertEqual(saved["description"] as? String, "a vendor tool") + + // Undeclared fields do not. Assert each by name: a count-only check would + // pass if one were dropped and another injected. + XCTAssertNil(saved["extra"], "unexpectedly preserved 'extra'") + XCTAssertNil(saved["listy"], "unexpectedly preserved 'listy'") + XCTAssertNil(saved["nested"], "unexpectedly preserved 'nested'") + + XCTAssertEqual( + Set(input.keys).subtracting(saved.keys), + ["extra", "listy", "nested"], + "the set of dropped fields changed") + } + + /// A *missing required* `connection` is silently accepted and materialises in + /// saved output as an empty, schema-invalid connection. + /// + /// `CustomTool.connection` is required (`schema/model/tools/tool.tsp:98` — no + /// `?`, no default). The generated loader only assigns it when the key is + /// present (`tools/tool.swift:167-169`), so an absent key leaves the + /// emitter-synthesised `Connection = .unknown([:])` placeholder in place, and + /// `save` then writes it unconditionally (`tools/tool.swift:198`). + /// + /// Two distinct problems, worth keeping apart: the loader does not report the + /// missing required field, and the placeholder it falls back to is not a + /// valid `Connection` — it has no `kind`. This is the runtime-visible half of + /// emitter defect 1b (see `schema/scripts/patch-swift-emitter-defects.mjs`), + /// which is otherwise easy to dismiss as a build-only annoyance the shim + /// absorbs. + /// + /// The Rust runtime does not do this: it stores an absent connection as + /// `Value::Null` and guards the write with `if !connection.is_null()` + /// (`runtime/rust/prompty/src/model/tools/tool.rs:176-179, 327-329`), so it + /// omits the key entirely. That makes this a Swift-specific divergence rather + /// than agreed cross-runtime behaviour. + /// + /// `options` is supplied here so it cannot confound the assertion: it has an + /// explicit `{}` default (`tool.tsp:102`) and is likewise saved + /// unconditionally, which is a separate empty-collection minimality question. + func testMissingRequiredConnectionIsSilentlyReplacedWithEmptyConnection() throws { + let input: [String: Any] = [ + "kind": "vendor_specific", "name": "vendorThing", "options": [String: Any](), + ] + + let saved = try Tool.load(input, context: LoadContext()).save(SaveContext()) + + XCTAssertNil(input["connection"], "fixture must not supply 'connection'") + XCTAssertEqual( + Set(saved.keys).subtracting(input.keys), + ["connection"], + "the set of injected fields changed") + + // Assert what it actually is, not merely that something is there — that is + // what ties the injected key to the synthesised `.unknown([:])` default. + let connection = try XCTUnwrap(saved["connection"] as? [String: Any]) + XCTAssertTrue(connection.isEmpty, "expected the empty placeholder, got \(connection)") + XCTAssertNil(connection["kind"], "a valid Connection would carry a discriminator") + } + + // MARK: - Connection: raw dictionary passthrough + + /// Raw passthrough DOES round-trip arbitrary top-level fields, including the + /// unrecognised discriminator itself. + func testUnknownConnectionPreservesEveryTopLevelField() throws { + let input: [String: Any] = [ + "kind": "vendor_auth", + "endpoint": "https://example.invalid", + "extra": "keepme", + "nested": ["deep": ["x": 1]], + ] + + let saved = try Connection.load(input, context: LoadContext()).save(SaveContext()) + + XCTAssertEqual( + Set(saved.keys), Set(input.keys), + "unknown Connection must neither drop nor inject top-level keys") + + // The discriminator survives verbatim. A typed subtype would be free to + // rewrite it, which is exactly what the Rust runtime does today. + XCTAssertEqual(saved["kind"] as? String, "vendor_auth") + XCTAssertEqual(saved["endpoint"] as? String, "https://example.invalid") + XCTAssertEqual(saved["extra"] as? String, "keepme") + + // Nested structure survives by value, not merely by presence. + let nested = saved["nested"] as? [String: Any] + let deep = nested?["deep"] as? [String: Any] + XCTAssertEqual(deep?["x"] as? Int, 1, "nested payload did not survive intact") + } + + /// The two strategies disagree on the same question, on the same input shape. + /// Pinned as a single assertion so the divergence cannot quietly close in + /// either direction without a test failing. + func testTheTwoWildcardStrategiesDisagreeOnUnknownFieldPreservation() throws { + let extras: [String: Any] = ["extra": "keepme", "nested": ["deep": ["x": 1]]] + + var toolInput: [String: Any] = ["kind": "vendor_specific", "name": "t"] + toolInput.merge(extras) { current, _ in current } + var connInput: [String: Any] = ["kind": "vendor_auth"] + connInput.merge(extras) { current, _ in current } + + let toolSaved = try Tool.load(toolInput, context: LoadContext()).save(SaveContext()) + let connSaved = try Connection.load(connInput, context: LoadContext()).save(SaveContext()) + + let toolKept = extras.keys.filter { toolSaved[$0] != nil }.sorted() + let connKept = extras.keys.filter { connSaved[$0] != nil }.sorted() + + XCTAssertEqual(toolKept, [], "typed wildcard subtype now preserves unknown fields") + XCTAssertEqual( + connKept, ["extra", "nested"], + "raw passthrough stopped preserving unknown fields") + XCTAssertNotEqual( + toolKept, connKept, + "the two strategies converged; the schema choice between them is no longer neutral") + } +} diff --git a/runtime/swift/prompty/Tests/PromptyTests/WireVectorTests.swift b/runtime/swift/prompty/Tests/PromptyTests/WireVectorTests.swift new file mode 100644 index 000000000..0521229f4 --- /dev/null +++ b/runtime/swift/prompty/Tests/PromptyTests/WireVectorTests.swift @@ -0,0 +1,124 @@ +import Foundation + +import PromptyModel + +import XCTest + +@testable import Prompty + +/// Conformance against `spec/vectors/wire/wire_vectors.json`. +/// +/// Builds a real agent from each vector's input and drives the real request +/// builders, so the assertion covers `ModelOptions.toWire`, tool projection and +/// structured-output wiring rather than a test-local approximation. +@testable import PromptyOpenAI + +final class WireVectorTests: XCTestCase { + + func testWireVectors() throws { + var run = VectorRun(stage: "wire") + + for vector in try Spec.vectors("wire") { + let name = vector["name"] as? String ?? "" + run.started() + let input = vector["input"] as? [String: Any] ?? [:] + let expected = vector["expected"] as? [String: Any] ?? [:] + + // This harness covers the OpenAI provider; Anthropic vectors belong to + // that provider's own package. + guard (input["provider"] as? String ?? "openai") == "openai" else { continue } + + do { + let agent = try Self.agent(from: input) + let messages = try Self.messages(from: input) + let apiType = input["apiType"] as? String ?? "chat" + + var body: [String: Any] + switch apiType { + case "chat", "agent": + body = try OpenAIWire.chatArgs(agent, messages: messages) + case "responses": + body = try OpenAIWire.responsesArgs(agent, messages: messages) + case "embedding": + body = OpenAIWire.embeddingArgs(agent, messages: messages) + case "image": + body = OpenAIWire.imageArgs(agent, messages: messages) + default: + throw InvokerError.execution("Unsupported apiType: \(apiType)") + } + + if input["stream"] as? Bool == true { + OpenAIWire.enableStreaming(&body, apiType: apiType) + } + + guard let expectedBody = expected["request_body"] else { continue } + try expectEqual(body, expectedBody, "request_body") + } catch { + run.fail(name, "\(error)") + } + } + + run.assertClean() + } + + // MARK: - Vector input decoding + + private static func agent(from input: [String: Any]) throws -> Prompty { + var model: [String: Any] = [ + "provider": input["provider"] as? String ?? "openai" + ] + if let id = input["model_id"] as? String { model["id"] = id } + if let apiType = input["apiType"] as? String { model["apiType"] = apiType } + if let options = input["options"] as? [String: Any], !options.isEmpty { + model["options"] = options + } + + var data: [String: Any] = [ + "kind": "prompt", + "name": "wire-vectors", + "model": model, + ] + if let tools = input["tools"] as? [Any], !tools.isEmpty { data["tools"] = tools } + if let outputs = input["outputs"] as? [Any], !outputs.isEmpty { data["outputs"] = outputs } + + return try Prompty.load(data) + } + + private static func messages(from input: [String: Any]) throws -> [Message] { + let raw = input["messages"] as? [[String: Any]] ?? [] + return try raw.map { entry in + var message = Message() + message.role = try Role.parse(entry["role"] as? String ?? "user") + message.parts = try (entry["content"] as? [[String: Any]] ?? []).map(part) + + // Vectors carry provider passthrough (e.g. tool_call_id) as sibling keys. + for (key, value) in entry where key != "role" && key != "content" { + message.metadata[key] = value + } + return message + } + } + + /// Vectors express every part's payload as `value` (plus optional + /// `mediaType`), which does not match the model's per-kind field names, so + /// the mapping is explicit. This mirrors the Rust vector runner. + private static func part(_ raw: [String: Any]) throws -> ContentPart { + let kind = raw["kind"] as? String ?? "text" + let value = raw["value"] as? String ?? "" + let mediaType = raw["mediaType"] as? String + + switch kind { + case "text": + return .textPart(TextPart(value: value)) + case "image": + return .imagePart( + ImagePart(source: value, detail: raw["detail"] as? String, mediaType: mediaType)) + case "audio": + return .audioPart(AudioPart(source: value, mediaType: mediaType)) + case "file": + return .filePart(FilePart(source: value, mediaType: mediaType)) + default: + throw InvokerError.parse("Unknown content kind: \(kind)") + } + } +} diff --git a/schema/scripts/normalize-typra-output.mjs b/schema/scripts/normalize-typra-output.mjs index 98544872d..d0aba8174 100644 --- a/schema/scripts/normalize-typra-output.mjs +++ b/schema/scripts/normalize-typra-output.mjs @@ -1,5 +1,6 @@ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { patchSwiftEmitterDefects } from "./patch-swift-emitter-defects.mjs"; const metadataRoot = join("tsp-output", ".typra-generated"); const manifestPath = join(metadataRoot, "manifest.json"); @@ -12,6 +13,22 @@ if (existsSync(manifestPath)) { trimEmptyPythonGeneratedTests(join("..", "runtime", "python", "prompty", "tests", "model")); trimTrailingWhitespace(join("..", "runtime", "go", "prompty", "model")); +// PROMPTY_SKIP_SWIFT_SHIM=1 emits raw, unpatched Swift so a candidate emitter +// build can be measured against the defects the shim compensates for. +// +// Retire the shim only when raw output passes `swift build --build-tests` AND +// `swift test` in runtime/swift/prompty -- a clean build alone is not enough, +// because dropped inherited fields on Property subtypes are read back through +// save() and so vanish silently at runtime. InheritedPropertyFieldTests is the +// gate for that. Restoring `test-dir` in tspconfig.yaml is a separate gate and +// does not block deleting this shim. +if (process.env.PROMPTY_SKIP_SWIFT_SHIM !== "1") { + patchSwiftEmitterDefects(); +} else { + console.warn( + "WARNING: Swift emitter shim skipped; generated Swift is unvalidated and must not be committed.", + ); +} function trimEmptyPythonGeneratedTests(root) { if (!existsSync(root)) { diff --git a/schema/scripts/patch-swift-emitter-defects.mjs b/schema/scripts/patch-swift-emitter-defects.mjs new file mode 100644 index 000000000..017af3464 --- /dev/null +++ b/schema/scripts/patch-swift-emitter-defects.mjs @@ -0,0 +1,902 @@ +// Temporary post-generation shim for @typra/emitter@0.4.2 Swift defects. +// +// The Swift generator emits code that does not compile. Every entry below has +// been reported upstream; this script applies the exact fixes the emitter should +// produce so `runtime/swift` stays buildable in the meantime. It runs as part of +// `npm run generate`, immediately after Typra writes its output, so the Swift +// model package remains fully machine-generated — no file is ever hand-edited. +// +// The three `Connection` patches have two different root causes, and conflating +// them is why successive emitter candidates could "fix the wildcard" while the +// Connection build errors persisted unchanged. They are split below into defect +// 1b (emitter-owned) and the "Schema gap" entry at the end of this list +// (schema-owned). Read the retirement notes carefully: which patches a given +// emitter release retires depends on *how* it fixes 1b, and 0.4.10 has already +// retired two of the three natively (see the version log at the end of file). +// +// Each patch is asserted. If a `find` pattern stops matching and the `replace` +// text is not already present, generation fails loudly: that is the signal that +// a new emitter release changed behaviour and this shim should be re-checked or +// deleted. +// +// Upstream emitter defects covered: +// 1a. Polymorphic enums emit a wildcard `load` branch without declaring the +// corresponding case (`Tool.customTool`) or a `save` branch. `CustomTool` +// is declared in TypeSpec as `kind: "*"` (`schema/model/tools/tool.tsp`), +// so the emitter simply fails to project a subtype the schema does define. +// 1b. The emitter emits `Connection = .unknown([:])` as the field default for +// every required `Connection`-typed field — six sites in `tools/tool.swift` +// (`CustomTool`, `McpTool`, `OpenApiTool`: one stored property and one +// `init` parameter each) — while `Connection` never declares an `unknown` +// case. When synthesising a default for a required field of a closed +// polymorphic union, the emitter reaches for a wildcard arm that it did +// not emit. This is self-contained emitter incorrectness: raw output fails +// to compile *on its own terms*, independent of how the schema question +// below is resolved. A conforming emitter must either declare the case or +// synthesise a different default. Verified by deleting only the injected +// `case unknown` line and rebuilding: 16 errors, all `type 'Connection' +// has no member 'unknown'`, at tool.swift:145/152/232/242/344/351 plus the +// two shim-injected arms in connection.swift. +// +// 1b is not merely a build break. `CustomTool.connection` is *required* +// (tool.tsp:98), but the generated loader only assigns it when the key is +// present (tool.swift:167-169), so an absent key leaves the synthesised +// `.unknown([:])` placeholder in place and `save` writes it unconditionally +// (tool.swift:198). Net effect: a missing required field is accepted +// without diagnostic and materialises in output as an empty, `kind`-less +// connection that is not a valid `Connection`. +// +// Rust does not do this — it stores an absent connection as `Value::Null` +// and guards the write with `if !connection.is_null()` (tool.rs:176-179, +// 327-329), omitting the key. So this is a Swift-specific divergence, and +// on any emitter that declares `case unknown` without also removing the +// bogus defaults, 1b degrades from a compile error into silent bad output. +// Report it with that consequence attached; "the shim already absorbs it" +// understates the impact. Measured by WildcardPreservationTests.swift. +// +// The `case unknown` declaration and `save` arm patches are this shim's +// chosen workaround for 1b — declaring the case is the smallest edit that +// makes the six defaults legal, and the `save` arm is then required only +// because that declaration makes the generated `switch` non-exhaustive. +// They are not the only possible fix, so what a corrected emitter retires +// depends on which fix it ships. Measured: 0.4.10 emits both the +// declaration and the `save` arm natively, retiring those two patches, and +// leaves `load`'s `default:` still throwing. +// 2. Self-recursive polymorphic enums are not marked `indirect` (`Property`). +// 3. Convenience factories pass raw literals where enum values are required +// (`Message.user/system/assistant`, `ToolResult.text`). +// 4. Protocol signatures leak unmapped placeholder type names (`Unknown`, +// `RecordUnknown`) instead of `Any` / `[String: Any]`. +// 5. Protocol signatures drop `[]` (array) and `?` (optional) type suffixes. +// 10. Fields inherited via `extends` are dropped from derived structs, so +// `ArrayProperty` / `ObjectProperty` / `UnionProperty` silently lose every +// base `Property` field (`description`, `required`, `nullable`, +// `default`, `example`, `enumValues`) and every `Tool` subtype loses +// `description` / `bindings`, on both load and save. +// +// `name` is injected alongside those, but for a *different* reason: it is +// not declared by `model Property` or `model Tool` at all. It arrives via +// the `Named<...>` spread (`schema/model/core/core.tsp`), which the emitter +// also drops. An upstream fix to `extends` inheritance therefore restores +// everything listed above *except* `name` — do not remove the `name` +// injection on the strength of an `extends` fix alone. Confirm it against +// regenerated output first. +// +// Schema gap (NOT an emitter defect — do not report it as one): +// `Connection` is declared as a closed union of six `kind` literals with no +// wildcard subtype (`schema/model/connection/connection.tsp`), so a +// conforming emitter is *correct* to close the enum and throw on an +// unrecognised discriminator. The `load` patch below deliberately overrides +// that and preserves the raw payload instead. +// +// Be precise about what does and does not currently mandate this. As of +// this branch `spec/spec.md` §2.5 only tabulates the six known kinds; it +// states no requirement about unrecognised ones, so the `load` patch is +// NOT satisfying a written contract today. What it follows is the adjacent +// established principle in §2.3 (lines 246-247): unknown top-level +// properties SHOULD be preserved and implementations MUST NOT raise on +// them. Extending that from unknown properties to unknown discriminator +// values is a deliberate forward-compatibility choice made here, pending a +// §2.5 amendment and a shared `connection_roundtrip` vector. The Swift +// suite carries a tripwire that fails once that vector lands, so this +// cannot be quietly forgotten (see ConnectionRoundTripTests.swift). +// +// Do not cite the Rust runtime as precedent for *lossless* round-tripping; +// it is precedent only for not throwing. Rust maps an unrecognised kind to +// `ConnectionKind::default()` (connection.rs:258) and `kind_str` has only +// the six arms (connection.rs:274-283), so it rewrites the discriminator on +// save and drops the subtype payload. Swift's `.unknown(object)` is +// strictly stronger. That divergence is itself unresolved cross-runtime +// behaviour, not settled parity. +// +// The durable fix is schema-owned: open the discriminator so unknown kinds +// are legal by construction. Exit condition — and this needs measuring, not +// assuming, because defect 1a proves a declared wildcard does not guarantee +// this emitter projects one correctly: once the schema opens the union, +// regenerate *with these three patches removed* and confirm (i) the package +// builds, (ii) raw generated `load`/`save` preserve an unrecognised kind's +// discriminator and payload byte-for-byte, and (iii) the Swift suite plus +// the shared round-trip vector pass. Only then delete them. +// +// Opening the discriminator is NOT the same as adding a typed wildcard +// subtype, and the difference is not cosmetic. Adding a `CustomTool`-style +// subtype (`schema/model/tools/tool.tsp:94`) is the obvious symmetry and +// gets proposed often. Measured on analogous inputs carrying the same +// unknown top-level fields (`extra`, `nested`): +// +// typed subtype (Tool/CustomTool) -> DROPS unknown fields; only the +// fields CustomTool declares +// survive +// raw passthrough (Connection) -> preserves all, unrecognised +// discriminator included +// +// State the conclusion at the width the evidence supports. What is +// measured is that a wildcard subtype declaring only *named* fields loses +// unknown ones — true by construction, and corroborated cross-runtime +// (Rust's `ToolKind::Custom` captures only `connection`/`options`/ +// `kind_name`, tool.rs:175-185). It does NOT follow that any conceivable +// `Connection` wildcard must, since one could declare raw catch-all +// storage. So the point is not "subtype bad" but that the two shapes are +// not interchangeable and the declared shape decides whether unknown +// fields survive. Choosing the subtype shape without a catch-all would +// give up preservation this runtime provides today. +// +// Do not overstate that as a spec violation: §2.3 (lines 246-247) permits +// unknown properties to be "preserved in `metadata` *or ignored*". Dropping +// them is legal; it is simply the weaker of two available guarantees. +// Pinned by WildcardPreservationTests.swift. Whether `CustomTool`'s own +// field-dropping is intended remains open, though Rust matching it suggests +// it is design rather than a Swift bug. +// +// Known limitation: injected base fields are added as properties and wired into +// `load` / `save`, but not into the generated memberwise `init`. Constructing a +// subtype in Swift therefore requires assigning those fields after `init`. That +// is deliberate — rewriting initializer signatures would change the emitter's +// public API surface far more invasively than restoring lossless round-trips, +// which is the only part the spec vectors depend on. + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const swiftSources = join("..", "runtime", "swift", "prompty-model", "Sources", "PromptyModel"); + +// --- Defect 10: base fields dropped from derived subtypes ------------------ +// The TypeSpec model declares `ArrayProperty`/`ObjectProperty`/`UnionProperty` +// as `extends Property`, and the five tool models as `extends Tool`, so each +// must carry the base fields. Every other generated language emits them; Swift +// does not. These are applied structurally (locate the struct, insert at its +// declaration / load / save anchors) rather than via literal find-and-replace, +// because the surrounding emitter output differs for every subtype. +// +// `Connection` subtypes are hit by the same emitter defect but are deliberately +// out of scope: the runtime never reads their inherited fields, so no vector +// regresses without them. The generated tests do assert them, though +// (`ReferenceConnection.authenticationMode` / `.usageDescription`), so +// restoring `test-dir` needs the upstream `extends` fix — not more injections +// here. +// +// That scoping decision has since been measured rather than assumed. A +// load/save probe over the composite subtypes — three `Property` kinds, five +// `Tool` kinds, and `ReferenceConnection` — scores 40/42 on this pinned +// configuration; the two failures are `ReferenceConnection.authenticationMode` +// and `.usageDescription`, lost between `load` and `save`. The same probe +// scores 42/42 against 0.4.10-generated output carrying two probe-only +// patches, so that release does declare and round-trip both fields. Injecting +// them here would restore data the Swift runtime never reads and no spec +// vector covers, at the cost of two more patches to delete on adoption — an +// accepted limitation of the published model package, not a free win. The +// behaviour is pinned across all six `Connection` subtypes by +// `GeneratedModelRoundTripTests.testConnectionBaseFieldsAreDroppedOnEverySubtype`, +// which fails if either field starts surviving. +const propertyBase = { + decls: + ' public var name: String = ""\n' + + " public var description: String? = nil\n" + + " public var required: Bool? = nil\n" + + " public var nullable: Bool? = nil\n" + + " public var `default`: Any? = nil\n" + + " public var example: Any? = nil\n" + + " public var enumValues: [Any]? = nil\n", + load: + ' if let value = object["name"] {\n' + + ' instance.name = try TypraRuntime.string(value, field: "name")\n' + + " }\n" + + ' if let value = object["description"], !(value is NSNull) {\n' + + ' instance.description = try TypraRuntime.string(value, field: "description")\n' + + " }\n" + + ' if let value = object["required"], !(value is NSNull) {\n' + + ' instance.required = try TypraRuntime.bool(value, field: "required")\n' + + " }\n" + + ' if let value = object["nullable"], !(value is NSNull) {\n' + + ' instance.nullable = try TypraRuntime.bool(value, field: "nullable")\n' + + " }\n" + + ' if let value = object["default"], !(value is NSNull) {\n' + + " instance.default = value\n" + + " }\n" + + ' if let value = object["example"], !(value is NSNull) {\n' + + " instance.example = value\n" + + " }\n" + + ' if let value = object["enumValues"], !(value is NSNull) {\n' + + ' instance.enumValues = try TypraRuntime.array(value, field: "enumValues")\n' + + " }\n", + save: + ' if !self.name.isEmpty { result["name"] = self.name }\n' + + ' if let value = self.description { result["description"] = value }\n' + + ' if let value = self.required { result["required"] = value }\n' + + ' if let value = self.nullable { result["nullable"] = value }\n' + + ' if let value = self.default { result["default"] = value }\n' + + ' if let value = self.example { result["example"] = value }\n' + + ' if let value = self.enumValues { result["enumValues"] = value }\n', +}; + +// `bindings` accepts either the `Record` map form or the +// `Named[]` list form; the map key supplies the binding name. +const toolBase = { + decls: + ' public var name: String = ""\n' + + " public var description: String? = nil\n" + + " public var bindings: [Binding]? = nil\n", + load: + ' if let value = object["name"] {\n' + + ' instance.name = try TypraRuntime.string(value, field: "name")\n' + + " }\n" + + ' if let value = object["description"], !(value is NSNull) {\n' + + ' instance.description = try TypraRuntime.string(value, field: "description")\n' + + " }\n" + + ' if let value = object["bindings"], !(value is NSNull) {\n' + + " if let mapping = value as? [String: Any] {\n" + + " instance.bindings = try mapping.keys.sorted().map { key in\n" + + " var binding = try Binding.load(mapping[key] as Any, context: context)\n" + + " binding.name = key\n" + + " return binding\n" + + " }\n" + + " } else {\n" + + ' instance.bindings = try TypraRuntime.array(value, field: "bindings").map {\n' + + " try Binding.load($0, context: context)\n" + + " }\n" + + " }\n" + + " }\n", + save: + ' if !self.name.isEmpty { result["name"] = self.name }\n' + + ' if let value = self.description { result["description"] = value }\n' + + " if let value = self.bindings {\n" + + ' result["bindings"] = try value.map { try $0.save(context) }\n' + + " }\n", +}; + +const baseFieldInjections = [ + { + file: join("core", "property.swift"), + structs: ["ArrayProperty", "ObjectProperty", "UnionProperty"], + base: propertyBase, + }, + { + file: join("tools", "tool.swift"), + structs: ["FunctionTool", "CustomTool", "McpTool", "OpenApiTool", "PromptyTool"], + base: toolBase, + }, +]; + +/** + * Insert `base` fields into one generated struct. + * + * Returns `{ changed }` on success, or throws when an anchor is missing — the + * signal that the emitter's Swift output shape changed. + */ +function countOccurrences(haystack, needle) { + if (!needle) return 0; + let count = 0; + let at = haystack.indexOf(needle); + while (at !== -1) { + count += 1; + at = haystack.indexOf(needle, at + needle.length); + } + return count; +} + +function injectBaseFields(content, structName, base) { + const header = `public struct ${structName}: TypraModel {\n`; + const start = content.indexOf(header); + if (start < 0) { + throw new Error(`struct ${structName} not found`); + } + const end = content.indexOf("\n}\n", start); + if (end < 0) { + throw new Error(`struct ${structName} has no closing brace`); + } + + let body = content.slice(start, end); + + // Treat the three insertions as one unit. A partially-applied struct means + // the emitter changed shape mid-flight and the shim can no longer be trusted. + const present = [base.decls, base.load, base.save].filter((part) => body.includes(part)); + if (present.length === 3) { + return { content, changed: false }; + } + if (present.length !== 0) { + throw new Error( + `struct ${structName} is partially patched (${present.length}/3 base-field blocks present) — ` + + "the emitter output shape changed; re-check this shim", + ); + } + + for (const [label, anchor, insert] of [ + ["declarations", "\n\n public init(", base.decls], + ["load", " return instance\n", base.load], + ["save", " return result\n", base.save], + ]) { + const at = body.indexOf(anchor); + if (at < 0) { + throw new Error(`struct ${structName} is missing its ${label} anchor ${JSON.stringify(anchor)}`); + } + // Anchors must be unambiguous — a second `return instance` (or `public + // init(`) would mean we are guessing which site to patch. + if (body.indexOf(anchor, at + anchor.length) >= 0) { + throw new Error( + `struct ${structName} has multiple ${label} anchors ${JSON.stringify(anchor)} — ` + + "cannot determine the insertion point", + ); + } + // Declarations append after the emitted vars; load/save prepend before the + // trailing `return`, so both cases insert at the anchor's start offset + 1 + // for declarations (past the first newline) and at the anchor for the rest. + const offset = label === "declarations" ? at + 1 : at; + body = body.slice(0, offset) + insert + body.slice(offset); + } + + return { content: content.slice(0, start) + body + content.slice(end), changed: true }; +} + +const patches = [ + // --- Defect 2: recursive polymorphic enum needs boxing ------------------- + { + file: join("core", "property.swift"), + find: "public enum Property: TypraModel {", + replace: "public indirect enum Property: TypraModel {", + }, + + // --- Defect 1a: missing wildcard case on `Tool` (emitter-owned) ---------- + // `CustomTool { kind: "*" }` IS declared in schema/model/tools/tool.tsp, so + // the emitter is failing to project a subtype the schema defines. Contrast + // with the `Connection` patches below, which straddle emitter and schema. + { + file: join("tools", "tool.swift"), + find: " case promptyTool(PromptyTool)\n", + replace: " case promptyTool(PromptyTool)\n case customTool(CustomTool)\n", + }, + { + file: join("tools", "tool.swift"), + find: " case .promptyTool(let value): return try value.save(context)\n", + replace: + " case .promptyTool(let value): return try value.save(context)\n" + + " case .customTool(let value): return try value.save(context)\n", + }, + // --- `Connection`: defect 1b (emitter) + schema gap (schema) ------------- + // Two root causes, kept together because one `case unknown` line serves both. + // The declaration and `save` arm work around defect 1b: the emitter emits + // `Connection = .unknown([:])` defaults in tools/tool.swift while never + // declaring the case, so raw output does not compile. 0.4.10 emits both of + // those natively, so a corrected emitter can retire them. The `load` arm is + // different: connection.tsp closes the union, so throwing is correct emitter + // behaviour, and overriding it is a deliberate forward-compatibility choice + // not yet backed by a written spec requirement. See the header for the + // measured exit condition — do not delete these on inspection alone. + { + file: join("connection", "connection.swift"), + find: " case foundryConnection(FoundryConnection)\n", + replace: " case foundryConnection(FoundryConnection)\n case unknown([String: Any])\n", + }, + { + file: join("connection", "connection.swift"), + find: + " default:\n" + + " throw TypraRuntimeError.unknownDiscriminator(\n" + + ' type: "Connection", field: "kind", value: discriminator)\n', + replace: + " default:\n" + + " // Deliberate forward-compatibility override, not an emitter defect.\n" + + " // `Connection` declares no wildcard subtype in TypeSpec, so closing\n" + + " // this enum and throwing here is correct emitter output. Preserving\n" + + " // the raw payload instead extends the spec's unknown-property rule\n" + + " // (spec.md 2.3) to unknown discriminator values, so that forward-\n" + + " // compatible files survive a load/save cycle. Note this is stronger\n" + + " // than the Rust runtime, which does not throw but does rewrite the\n" + + " // discriminator and drop the payload. Retires only once the schema\n" + + " // opens the union and regenerated output is measured to preserve\n" + + " // unknown kinds on its own.\n" + + " return .unknown(object)\n", + }, + { + file: join("connection", "connection.swift"), + find: " case .foundryConnection(let value): return try value.save(context)\n", + replace: + " case .foundryConnection(let value): return try value.save(context)\n" + + " case .unknown(let value): return value\n", + }, + + // --- Defect 3: convenience factories must use enum constructors ---------- + { + file: join("conversation", "message.swift"), + find: 'Message(role: "assistant", parts: [TextPart(kind: "text", value: text)])', + replace: 'Message(role: .assistant, parts: [.textPart(TextPart(kind: "text", value: text))])', + }, + { + file: join("conversation", "message.swift"), + find: 'Message(role: "system", parts: [TextPart(kind: "text", value: text)])', + replace: 'Message(role: .system, parts: [.textPart(TextPart(kind: "text", value: text))])', + }, + { + file: join("conversation", "message.swift"), + find: 'Message(role: "user", parts: [TextPart(kind: "text", value: text)])', + replace: 'Message(role: .user, parts: [.textPart(TextPart(kind: "text", value: text))])', + }, + { + file: join("conversation", "tool_result.swift"), + find: 'ToolResult(parts: [TextPart(kind: "text", value: value)])', + replace: 'ToolResult(parts: [.textPart(TextPart(kind: "text", value: value))])', + }, + + // --- Defects 4 + 5: protocol signature type mapping and arity ------------ +// Note for whoever retires these: the `parser.swift` `replace` below is +// pre-wrapped to the width `swift-format` produces. A fixed emitter emits the +// same signature on one line, which matches neither `find` nor `replace`, so +// this patch reports "output changed" rather than "already applied". That is a +// false negative — compare the signatures, not the line breaks, before +// concluding the emitter still has the defect. + { + file: join("pipeline", "parser.swift"), + find: " func preRender(template: String) throws -> Unknown\n" + + " func parse(agent: Prompty, rendered: String, context: RecordUnknown) async throws -> Message\n", + replace: " func preRender(template: String) throws -> Any?\n" + + " func parse(agent: Prompty, rendered: String, context: [String: Any]?) async throws\n" + + " -> [Message]\n", + }, + { + file: join("pipeline", "renderer.swift"), + find: " func render(agent: Prompty, template: String, inputs: RecordUnknown) async throws -> String\n", + replace: " func render(agent: Prompty, template: String, inputs: [String: Any]) async throws -> String\n", + }, + { + file: join("pipeline", "executor.swift"), + find: " func execute(agent: Prompty, messages: Message) async throws -> Any\n" + + " func executeStream(agent: Prompty, messages: Message) async throws -> Any\n" + + " func formatToolMessages(\n" + + " rawResponse: Any, toolCalls: ToolCall, toolResults: String, textContent: String\n" + + " ) throws -> Message\n", + replace: " func execute(agent: Prompty, messages: [Message]) async throws -> Any\n" + + " func executeStream(agent: Prompty, messages: [Message]) async throws -> Any\n" + + " func formatToolMessages(\n" + + " rawResponse: Any, toolCalls: [ToolCall], toolResults: [String], textContent: String?\n" + + " ) throws -> [Message]\n", + }, + { + file: join("model", "model_lister.swift"), + find: " func listModels(connection: Any) async throws -> ModelInfo\n", + replace: " func listModels(connection: Any) async throws -> [ModelInfo]\n", + }, + { + file: join("pipeline", "checkpoint_store.swift"), + find: " func load(sessionId: String, checkpointId: String) async throws -> Checkpoint\n" + + " func listCheckpoints(sessionId: String) async throws -> Checkpoint\n", + replace: " func load(sessionId: String, checkpointId: String) async throws -> Checkpoint?\n" + + " func listCheckpoints(sessionId: String) async throws -> [Checkpoint]\n", + }, + { + file: join("pipeline", "event_journal_writer.swift"), + find: " func close(summary: SessionSummary) throws -> Bool\n", + replace: " func close(summary: SessionSummary?) throws -> Bool\n", + }, +]; + +/// The emitter release this shim was written against. Every patch below encodes +/// the exact byte sequences that version emits, so a different version must not +/// be silently patched — it needs a re-review (and is quite possibly fixed). +/// +/// Releases evaluated and rejected so far, all because the emitter is shared +/// and a bump regenerates every runtime: +/// 0.4.3 — fixes five Swift source-generator defects (Tool wildcard, indirect, +/// typed factories, placeholder types, suffix loss) and would cut this +/// shim to 345 lines, but reshapes the C# generated-test string +/// literals and breaks 16 `*Yaml*` tests in Prompty.Core.Tests. +/// 0.4.5 — probed because it was announced as carrying the Swift +/// generated-test fixes. It predates 0.4.6 and 0.4.9, so it lacks the +/// inherited `extends` fields fix noted below and still omits +/// `Connection.unknown`: native output is 30 errors, all `Connection +/// has no member 'unknown'`, in tool.swift. Probe-patching only that +/// case makes the library compile and then yields 228 test-build +/// error lines, 57 unique, across six files, against 0.4.9's 180 and +/// 45 across five. The sixth is tools/ToolTests.swift, whose eight +/// errors are all `FunctionTool` missing `name` and `description`. +/// `ReferenceConnection` likewise drops `authenticationMode` and +/// `usageDescription`, but silently, as defect 10 describes. +/// Re-probed patch by patch: of this shim's 24 patch sites, 13 are +/// fixed upstream (defects 2, 3, 4 and 5, plus defect 1 for `Tool`) +/// and 11 are residual — the three `Connection.unknown` patches and +/// all eight base-field injections. Applying only those 11 to native +/// 0.4.5 output compiles the library, clears `ToolTests` entirely, +/// leaves 49 unique test-build errors across five files, and passes +/// all 76 runtime tests including live E2E. 0.4.5 would therefore +/// retire 13 of the 24 patch sites but not the shim itself: the eight +/// residual injections keep the structural machinery, which is the +/// bulk of the code here. Rejected on Swift alone, so the other +/// runtimes were not measured here. +/// 0.4.6 — additionally fixes inherited `extends` fields, but carries the same +/// C# break plus dropped `= []` on 13 TypeScript fields that declare an +/// explicit `= #[]` default. +/// 0.4.7 — the strongest Swift release measured. Native output fails with just +/// 6 primary unique errors, all `Connection has no member 'unknown'`, +/// all in tools/tool.swift, from the +/// `connection: Connection = .unknown([:])` defaults on the Mcp, +/// OpenApi and Custom tool structs. Applying only the three +/// `Connection` patches below takes `swift build` to exit 0, so 13 of +/// the 16 literal patches are fixed and those three are the whole +/// compile-blocking residual. The eight base-field injections are +/// redundant here for the fields they restore: with only the +/// `Connection` patch applied, +/// `testPropertyBaseFieldsRoundTripOnEverySubtype` and +/// `testToolBaseFieldsRoundTripOnEverySubtype` both pass against +/// native output. All six `Connection` subtypes additionally declare +/// `authenticationMode` and `usageDescription`, which 0.4.2 declares +/// on none, so +/// `GeneratedModelRoundTripTests.testConnectionBaseFieldsAreDroppedOnEverySubtype` +/// fires 12 failures here. That is the characterization test working +/// as designed, not a regression: it signals the behaviour it pins has +/// improved and should become a preservation assertion on adoption. +/// +/// Rejected on a defect earlier probes did not catch, because they did +/// not exercise this path — this was the first probe to run the shared +/// spec vectors against native output rather than stopping at +/// compilation. `tool.tsp:18` declares +/// `alias Bindings = Record | Named`, a union of +/// a name-keyed map and a named list. The emitter implements only the +/// `Named<>` arm — `bindings` loads through `TypraRuntime.array`, so +/// the `Record<>` map form throws `Expected array for field bindings.` +/// Two shared vectors fail as a result, `tools_function_load` and +/// `tools_bindings_stripped`, and the consumer suite reports 77 +/// executed with 15 failures across 4 tests: 12 from the +/// characterization test above, and one each from +/// `testToolBindingsLoadFromMapForm`, `LoadVectorTests` and +/// `WireVectorTests`, all three the same map arm. Verified by +/// execution, not inference: the list form, and the `@coerce` scalar +/// shorthand within it, both load correctly; the map form and the +/// shorthand nested inside it both throw. The missing arm is carried +/// by `toolBase.load` above, keyed off `mapping.keys.sorted()` with the +/// map key supplying `binding.name`; the sort is what makes output +/// deterministic. So the five `Tool` injections are only redundant for +/// the fields they restore — retiring them as base-field duplicates +/// would also delete this map handling, which native 0.4.7 does not +/// replace. +/// +/// Two notes for the entries around this one. First, the `Record<>` map +/// arm was not exercised by the 0.4.5, 0.4.8, 0.4.9 or 0.4.10 probes, +/// which measured compilation and, at 0.4.10, `load`/`save` on +/// hand-built values — so read 0.4.10's "lone residual" as scoped to +/// what that probe covered, not as excluding this. Its status there is +/// unmeasured and must be checked before adoption. Second, the +/// compiler-breaking named-dict forwarders that rejected 0.4.9 and +/// 0.4.10 (`item.name = name`, `Property.shorthandProperty` against the +/// polymorphic enums) did not manifest in compiled 0.4.7 output; that +/// is a compile-level observation only, and establishing when they were +/// introduced needs a generated-source diff, not this build result. +/// The generated `test-dir` is 45 primary unique errors across five +/// files, the same total as 0.4.9 and 0.4.10, with `ConformanceTests` +/// clean — identities were not diffed against those releases, so do not +/// read the equal totals as the same failures. +/// C#, TypeScript, Go and Rust were not measured at 0.4.7. +/// 0.4.8 — Swift is close to clean: native output compiles to exactly 30 +/// errors, all `Connection has no member 'unknown'`, all in tool.swift. +/// Probe-patching only that case makes the model package compile, so +/// this shim would likely collapse to a single patch — but "compiles" +/// is not "correct" (see defect 10 above: silently dropped fields +/// produce no diagnostics), so retiring any patch still requires +/// `swift build --build-tests` *and* `swift test` to confirm the +/// round-trip behaviour it guards. Rejected regardless: the C# break +/// below reproduces at 0.4.8 (verified locally). The 0.4.6 TypeScript +/// regression was not re-verified at 0.4.8. +/// 0.4.9 — emits `Connection.unknown` in the requested shape, and +/// Prompty.Core.Tests reports 48 passed, 0 failed, beating the 0.4.2 +/// baseline, which fails 16 `*Json*` tests locally due to a CRLF +/// artifact. Go has 17 `FAIL` lines, down from 44, and Rust is green +/// (4 + 33 + 6 passed, 0 failed). Rejected anyway: a new named-dict +/// collection helper — added so `inputs:`/`tools:` can be read as +/// name-keyed maps — is generated assuming a struct element type. For +/// the polymorphic enums `Property` and `Tool`, it emits +/// `item.name = name` and the corresponding `.shorthandProperty`, +/// although those enums declare only cases and `load`/`save`. That is +/// 48 fresh compile errors (agent/prompty.swift 30, core/property.swift +/// 10, tools/tool.swift 8) where 0.4.8 had 30, so the shim cannot +/// shrink, let alone be deleted. Routing the name through the +/// dictionary before `load` — mirroring the save path, which already +/// does `removeValue(forKey: "name")` — would make the helper agnostic +/// to struct-vs-enum elements. The generated `test-dir` is also no +/// better: after probe-patching those 48 source errors so the library +/// could compile, it produced the same 180 test errors as 0.4.8, so +/// restoring it stays blocked too. The 0.4.6 TypeScript `= []` +/// regression persists. +/// 0.4.10 — the closest to adoptable so far: 23 of the 24 patch sites below are +/// fixed upstream, against 13 at 0.4.5. Every base-field injection is +/// fixed upstream — `ArrayProperty`, `ObjectProperty`, `UnionProperty` +/// carry the `Property` fields and all five `Tool` subtypes carry +/// `name`, `description`, `bindings` — and, unlike earlier attempts, +/// those fields appear in the memberwise `init`, which would also +/// settle the known limitation recorded at the top of this file. Note +/// that nothing here is retired today: this repo stays pinned to +/// 0.4.2, so all 24 patches still apply. `Connection` gains +/// `case unknown([String: Any])` and its `save` arm. The lone residual +/// is `Connection.load`'s `default:`, which still throws +/// `unknownDiscriminator` instead of returning `.unknown`, leaving +/// that case declared but unreachable by the loader. Read that +/// "lone residual" as scoped to what this probe measured — it did not +/// run the spec vectors against native output, so it did not test the +/// `Record` map arm described in the 0.4.7 entry above. +/// Rejected because the 0.4.9 named-dict defect persists, though much +/// reduced: `item.name = name` and `Property.shorthandProperty` are +/// still emitted against the polymorphic enums, now costing 10 errors +/// confined to agent/prompty.swift where 0.4.9 cost 48 across three +/// files. core/property.swift and tools/tool.swift no longer fail; +/// the probe did not establish why, so do not assume the base-field +/// fix is the cause. Hand-synthesising the enum-level forwarders, plus +/// the one-line `Connection` change, takes `swift build` from 10 +/// errors to exit 0. That measures compilation only — the forwarders +/// are themselves a workaround, and with the generated tests still +/// unusable nothing here demonstrates behavioural correctness, so +/// deleting this shim needs both fixes upstream *and* a green +/// generated-test run. It is the first release where that outcome +/// looks reachable, which is why 0.4.10 is worth re-probing rather +/// than skipping. The generated `test-dir` remains unusable: 45 +/// primary unique errors across five files (PromptyTests 16, +/// ModelTests 12, PropertyTests 8, McpApprovalModeTests 5, +/// ConnectionTests 4) against 0.4.5's 49 across five. Treat that as a +/// count, not a trend — failure identities were not diffed. ToolTests +/// is clean here, but it was already clean in the 0.4.5 49-error +/// measurement; its eight failures belong to a less-patched 0.4.5 +/// probe and are not a 0.4.10 improvement. At least four of the +/// ModelTests failures are ours, not the emitter's — see the +/// `@sample` defect at schema/model/model/model.tsp L86/L93. +/// C# and TypeScript were not re-measured at 0.4.10. +/// Round-trip evidence: a load/save probe over three `Property` +/// kinds, five `Tool` kinds including the wildcard, +/// `ReferenceConnection`, and the `Connection` unknown fallback +/// scores 42/42 against 0.4.10-generated output carrying the enum +/// forwarders and the one-line `Connection` change, against 40/42 on +/// this pinned 0.4.2 configuration. The two-check delta is the +/// deliberate `Connection` scope gap described near `propertyBase`, +/// not a regression, and the probe covered only `ReferenceConnection` +/// of the six connection subtypes. That result covers `load`/`save` +/// on those types alone; it says nothing about the generated tests, +/// which stay unusable. One behavioural difference is worth carrying +/// forward: this shim writes `name` only when non-empty, while +/// 0.4.10 writes it unconditionally, so an unnamed composite +/// serialises `"name": ""` there where it omits the key here. +/// `Prompty.save` maps `inputs`/`outputs`/`tools` straight through +/// without stripping `name`, so that difference can reach vector +/// output; it was not measured against the vectors and must be +/// settled before adopting, not after. +/// Contract note: `Tool` now emits both `customTool(CustomTool)` and +/// `unknown([String: Any])`, but `load`'s `default:` routes to +/// `.customTool`, so `.unknown` is unreachable through `load` — it is +/// still manually constructible, but it forces any exhaustive +/// consumer `switch` without a catch-all arm to grow one. +/// 0.4.11 — the first builds among the 29 archives probed here that fix the +/// collection-helper inheritance defect. Every artifact named in +/// this entry has since been withdrawn by the release owner and is +/// ineligible: do not install, probe, adopt, or cite any of it as +/// acceptance evidence. What survives is the acceptance *marker*, +/// which is artifact-independent. The fix merges +/// `collectionHelpers` across ancestors in +/// `dist/src/ir/inheritance.js`; without it a +/// `Record | Named` alias declared on a *base* type loses +/// its dual-form helper in every subtype — exactly the +/// `Tool.bindings` map arm that rejected 0.4.7 above. Presence of +/// that merge is the acceptance marker. +/// +/// Version labels do not identify these bytes. Twenty-nine distinct +/// tarballs were content-hashed: no 0.4.9 or 0.4.10 archive among +/// them carries the fix, including one repeatedly circulated as "the +/// candidate" (sha256 317249BFAC..., 197971 B). Two *different* +/// archives are both labelled 0.4.9, and 0.4.11-333d8f390456 is +/// byte-identical to 0.4.11-72b51ec5437b. `gitHead` is empty in every +/// archive opened, so bytes cannot be mapped back to a commit from +/// the artifact alone. Key acceptance on sha256 and on the marker +/// above, never on the version string. +/// +/// Two builds were once validated end to end against this repo +/// (2C405A0AF5..., 29151169CD...), both since withdrawn and +/// ineligible. They are recorded for the failure *shapes* they +/// exposed, never as candidates. Both generated cleanly +/// with this shim disabled, and `swift build --build-tests` reached +/// zero errors after a single consumer adaptation — +/// `ContentPart.unknown` in `PromptyOpenAI/Wire.swift`. `swift test` +/// then reported 80 executed with 17 failures. Sixteen are +/// characterization tripwires in this repo firing *because* the +/// upstream fixes landed: 12 from +/// `testConnectionBaseFieldsAreDroppedOnEverySubtype`, whose messages +/// read "... now survives", and 4 from +/// `testBareScalarShorthandIsNotCoerced`, "... the emitter grew +/// @coerce support". Each of those names the assertion it should +/// become on adoption. The seventeenth is *not* a tripwire: +/// `testNestedPropertySubtypesRoundTrip` is a positive invariant +/// asserting `save()` equals its source, and its whole-dict delta was +/// never inspected field by field. Do not read it as upstream-fixed +/// behaviour — identify the differing keys before adopting. +/// +/// Wildcard contract, settled for `Tool`: when a polymorphic enum +/// declares a wildcard subtype, the emitter should not also emit an +/// `unknown` fallback. The wildcard already absorbs unrecognised +/// discriminators, so the fallback is unreachable through `load` and +/// adds a case that loaded values can never occupy — it stays +/// manually constructible, as the 0.4.10 note above records, but no +/// loader can produce it. The 0.4.10 entry recorded that defect for +/// `Tool`; the withdrawn 29151169CD... resolved it, emitting `Tool` +/// as +/// `functionTool | mcpTool | openApiTool | promptyTool | customTool` +/// with no `unknown`. Neither `Property` nor `ContentPart` declares a +/// `kind: "*"` subtype, yet both still emit `unknown` — which is what +/// forces the `Wire.swift` adaptation above. Do not read that as +/// sanctioned: `content.tsp` closes `ContentPart` to four variants, +/// and the current pinned output rejects an unknown discriminator +/// outright, so `ContentPart.unknown` is a behavioural change needing +/// a contract decision before adoption, not a settled one. +/// `Property.unknown` stands on different ground — it carries the +/// scalar `SimpleTypes` shorthand, which has no concrete subtype +/// model — so the two should be decided separately. +/// Compare failing test *identities*, not counts: the 0.4.3, 0.4.6, and 0.4.8 +/// releases evaluated against C# each leave its failure count at 16 while +/// swapping `*Json*` for `*Yaml*`. On Windows those are two unrelated causes — +/// the baseline `*Json*` failures are a local CRLF artifact, while the new +/// `*Yaml*` ones come from a trailing space at schema/model/agent/agent.tsp:166 +/// that escaped expected-value literals preserve and verbatim input-YAML +/// literals drop. 0.4.9 resolves that asymmetry. +/// +/// `npm run generate` is not the whole pipeline: `npm run build` is +/// `format:tsp && generate && format:rust`, and it is `format:rust` +/// (`cargo fmt --all` over the Rust runtime) that reconciles the emitter's raw +/// output with the formatted files this repo commits. Running `generate` alone +/// therefore leaves the Rust tree dirty. Measured at the pinned 0.4.2: 287 +/// modified Rust files (3881 insertions, 12478 deletions), and `format:rust` +/// alone restored an exactly clean tree — so that particular delta was +/// whitespace, not semantics. Do not generalise it: a future delta surviving +/// formatting is a real change, which is the point of re-checking. Swift output +/// was byte-identical straight from the emitter and needs no such step. Prefer +/// `npm run build`, and re-read `git status` after formatting rather than +/// reading a dirty Rust tree as an emitter change. +/// +/// Confirm every "still residual" verdict by reading generated source, never by +/// matching the `find` anchors below. Those anchors are only a hypothesis about +/// a release: at 0.4.5 they were reliable, but at 0.4.10 they reported all eight +/// base-field injections as unfixed when the source proves otherwise, because +/// the structural anchors `injectBaseFields` keys off had moved. Two distinct +/// false-negative mechanisms are now known — shifted structural anchors, and the +/// pre-wrapped `replace` text described above the Defects 4 + 5 group. +const PINNED_EMITTER_VERSION = "0.4.2"; + +function assertPinnedEmitterVersion() { + const manifestPath = join("node_modules", "@typra", "emitter", "package.json"); + if (!existsSync(manifestPath)) { + throw new Error( + `Cannot verify the emitter version: ${manifestPath} is missing. The Swift shim only ` + + `applies to @typra/emitter@${PINNED_EMITTER_VERSION}; refusing to patch generated ` + + "Swift against an unknown emitter. Run `npm install` in schema/ first.", + ); + } + const { version } = JSON.parse(readFileSync(manifestPath, "utf8")); + if (version !== PINNED_EMITTER_VERSION) { + throw new Error( + `Swift emitter shim is pinned to @typra/emitter@${PINNED_EMITTER_VERSION} but ` + + `@typra/emitter@${version} is installed. Re-verify every patch in ` + + "schema/scripts/patch-swift-emitter-defects.mjs against the new output, then update the pin " + + "(or delete the shim if the defects are fixed upstream).", + ); + } +} + +export function patchSwiftEmitterDefects(root = swiftSources) { + assertPinnedEmitterVersion(); + + if (!existsSync(root)) { + // Swift is a configured emit target, so a missing output tree means the + // emitter silently produced nothing — never treat that as success. + throw new Error( + `Swift emitter shim found no generated output at ${root}. ` + + "Check the Swift emit target in schema/tspconfig.yaml.", + ); + } + + let applied = 0; + let alreadyApplied = 0; + const failures = []; + + for (const patch of patches) { + const path = join(root, patch.file); + if (!existsSync(path)) { + failures.push(`${patch.file}: generated file is missing`); + continue; + } + + const content = readFileSync(path, "utf8"); + // `replace` is checked first because several patches are insertions whose + // replacement text still contains the `find` anchor; checking `find` first + // would re-apply them on every run. + const replaceCount = countOccurrences(content, patch.replace); + if (replaceCount > 0) { + if (replaceCount !== 1) { + failures.push( + `${patch.file}: the patched form appears ${replaceCount} times; expected exactly one. ` + + `The Swift emitter output changed — re-verify this shim.`, + ); + continue; + } + // Guard against a half-patched file: strip the patched occurrences and + // confirm no raw anchor survives elsewhere. + const residual = countOccurrences(content.split(patch.replace).join(""), patch.find); + if (residual > 0) { + failures.push( + `${patch.file}: found the patched form plus ${residual} unpatched occurrence(s) of the anchor. ` + + `Refusing to leave a half-patched file — re-verify this shim.`, + ); + continue; + } + alreadyApplied += 1; + } else { + const findCount = countOccurrences(content, patch.find); + if (findCount !== 1) { + failures.push( + findCount === 0 + ? `${patch.file}: neither the expected emitter output nor the patched form was found. ` + + `The Swift emitter output changed — re-verify this shim.\n expected: ${JSON.stringify(patch.find)}` + : `${patch.file}: the anchor is ambiguous (${findCount} occurrences); expected exactly one. ` + + `Refusing to patch — re-verify this shim.\n anchor: ${JSON.stringify(patch.find)}`, + ); + continue; + } + writeFileSync(path, content.split(patch.find).join(patch.replace)); + applied += 1; + } + } + + for (const injection of baseFieldInjections) { + const path = join(root, injection.file); + if (!existsSync(path)) { + failures.push(`${injection.file}: generated file is missing`); + continue; + } + + let content = readFileSync(path, "utf8"); + let dirty = false; + for (const structName of injection.structs) { + try { + const result = injectBaseFields(content, structName, injection.base); + content = result.content; + if (result.changed) { + applied += 1; + dirty = true; + } else { + alreadyApplied += 1; + } + } catch (error) { + failures.push(`${injection.file}: ${error.message}`); + } + } + if (dirty) { + writeFileSync(path, content); + } + } + + if (failures.length > 0) { + throw new Error( + `Swift emitter shim is stale:\n - ${failures.join("\n - ")}\n` + + "If @typra/emitter now emits correct Swift, delete schema/scripts/patch-swift-emitter-defects.mjs " + + "and its call in normalize-typra-output.mjs.", + ); + } + + return { applied, alreadyApplied, skipped: false }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const result = patchSwiftEmitterDefects(); + console.log( + `swift emitter shim: ${result.applied} patched, ${result.alreadyApplied} already applied`, + ); +} diff --git a/schema/tsp-output/.typra-generated/export-surfaces.json b/schema/tsp-output/.typra-generated/export-surfaces.json index 45c400eb4..789b155cb 100644 --- a/schema/tsp-output/.typra-generated/export-surfaces.json +++ b/schema/tsp-output/.typra-generated/export-surfaces.json @@ -10031,6 +10031,2055 @@ "wire" ] }, + { + "target": "swift", + "outputRoot": "../runtime/swift/prompty-model", + "packageName": "PromptyModel", + "rootExports": [ + "AiResourceInfo", + "AnonymousConnection", + "AnthropicImageBlock", + "AnthropicImageSource", + "AnthropicMessagesRequest", + "AnthropicMessagesResponse", + "AnthropicTextBlock", + "AnthropicToolDefinition", + "AnthropicToolResultBlock", + "AnthropicToolUseBlock", + "AnthropicUsage", + "AnthropicWireMessage", + "ApiKeyConnection", + "ArrayProperty", + "AudioPart", + "AuthorizationCodeFlow", + "Binding", + "Checkpoint", + "CheckpointStore", + "CompactionCompletePayload", + "CompactionConfig", + "CompactionFailedPayload", + "CompactionStartPayload", + "Connection", + "ContentPart", + "ContextCandidate", + "ContextRequest", + "CustomTool", + "DelegatedStateReference", + "DeviceAuthorization", + "DoneEventPayload", + "EngineCheckpoint", + "EngineEvent", + "EnginePermissionDecision", + "ErrorChunk", + "ErrorEventPayload", + "EventJournalWriter", + "EventSink", + "Executor", + "FileNotFoundError", + "FilePart", + "FinalOutputPolicyRequest", + "FinalOutputPolicyResult", + "FormatConfig", + "FoundryConnection", + "FunctionTool", + "GuardrailResult", + "HarnessContext", + "HookEndPayload", + "HookStartPayload", + "HostPolicyRequest", + "HostPolicyResult", + "HostToolExecutor", + "HostToolRequest", + "HostToolResult", + "ImagePart", + "InvocationContextDecision", + "InvocationContextState", + "InvocationUsage", + "InvokerError", + "LlmCompletePayload", + "LlmStartPayload", + "McpApprovalMode", + "McpTool", + "MemoryEntry", + "MemoryStore", + "Message", + "MessagesUpdatedPayload", + "Model", + "ModelInfo", + "ModelInvocationContextSnapshot", + "ModelInvocationRequest", + "ModelInvocationResponse", + "ModelLister", + "ModelOptions", + "ModelReconciliationState", + "ModelToolRequest", + "ModelToolResult", + "OAuthConnection", + "OAuthToken", + "ObjectProperty", + "OpenApiTool", + "Parser", + "ParserConfig", + "PermissionCompletedPayload", + "PermissionDecision", + "PermissionRequest", + "PermissionRequestedPayload", + "PermissionResolver", + "Processor", + "ProjectInfo", + "Prompty", + "PromptyTool", + "Property", + "RedactedField", + "RedactionMetadata", + "ReferenceConnection", + "RemoteConnection", + "Renderer", + "ReplayJournalRecord", + "ReplayMismatch", + "ReplayVerificationRequest", + "ReplayVerificationResult", + "ResumeContext", + "RetryPayload", + "RetryPolicyRequest", + "RunTurnRequest", + "RunTurnResult", + "SessionEndPayload", + "SessionEvent", + "SessionFileRef", + "SessionRef", + "SessionStartPayload", + "SessionSummary", + "SessionTrace", + "SessionWarningPayload", + "StatusEventPayload", + "StreamChunk", + "StreamOptions", + "SubscriptionInfo", + "Template", + "TextChunk", + "TextPart", + "ThinkingChunk", + "ThinkingEventPayload", + "ThreadMarker", + "TokenEventPayload", + "TokenUsage", + "Tool", + "ToolCall", + "ToolCallCompletePayload", + "ToolCallStartPayload", + "ToolChunk", + "ToolContext", + "ToolDispatchResult", + "ToolExecutionCompletePayload", + "ToolExecutionStartPayload", + "ToolResult", + "ToolResultPayload", + "TraceFile", + "TraceSpan", + "TraceTime", + "TrajectoryEvent", + "TurnCommit", + "TurnEndPayload", + "TurnEngineResult", + "TurnEvent", + "TurnModelRequest", + "TurnModelResponse", + "TurnOptions", + "TurnStartPayload", + "TurnSummary", + "TurnTrace", + "UnionProperty", + "UsageChunk", + "ValidationError", + "ValidationResult" + ], + "exports": [ + { + "name": "GuardrailResult", + "kind": "value", + "group": "agent", + "source": "Sources/PromptyModel/agent/guardrail_result.swift", + "protocol": false + }, + { + "name": "Prompty", + "kind": "value", + "group": "agent", + "source": "Sources/PromptyModel/agent/prompty.swift", + "protocol": false + }, + { + "name": "AnonymousConnection", + "kind": "value", + "group": "connection", + "source": "Sources/PromptyModel/connection/connection.swift", + "protocol": false + }, + { + "name": "ApiKeyConnection", + "kind": "value", + "group": "connection", + "source": "Sources/PromptyModel/connection/connection.swift", + "protocol": false + }, + { + "name": "AuthorizationCodeFlow", + "kind": "value", + "group": "connection", + "source": "Sources/PromptyModel/connection/authorization_code_flow.swift", + "protocol": false + }, + { + "name": "Connection", + "kind": "value", + "group": "connection", + "source": "Sources/PromptyModel/connection/connection.swift", + "protocol": false + }, + { + "name": "DeviceAuthorization", + "kind": "value", + "group": "connection", + "source": "Sources/PromptyModel/connection/device_authorization.swift", + "protocol": false + }, + { + "name": "FoundryConnection", + "kind": "value", + "group": "connection", + "source": "Sources/PromptyModel/connection/connection.swift", + "protocol": false + }, + { + "name": "OAuthConnection", + "kind": "value", + "group": "connection", + "source": "Sources/PromptyModel/connection/connection.swift", + "protocol": false + }, + { + "name": "OAuthToken", + "kind": "value", + "group": "connection", + "source": "Sources/PromptyModel/connection/o_auth_token.swift", + "protocol": false + }, + { + "name": "ReferenceConnection", + "kind": "value", + "group": "connection", + "source": "Sources/PromptyModel/connection/connection.swift", + "protocol": false + }, + { + "name": "RemoteConnection", + "kind": "value", + "group": "connection", + "source": "Sources/PromptyModel/connection/connection.swift", + "protocol": false + }, + { + "name": "AudioPart", + "kind": "value", + "group": "conversation", + "source": "Sources/PromptyModel/conversation/content_part.swift", + "protocol": false + }, + { + "name": "ContentPart", + "kind": "value", + "group": "conversation", + "source": "Sources/PromptyModel/conversation/content_part.swift", + "protocol": false + }, + { + "name": "FilePart", + "kind": "value", + "group": "conversation", + "source": "Sources/PromptyModel/conversation/content_part.swift", + "protocol": false + }, + { + "name": "ImagePart", + "kind": "value", + "group": "conversation", + "source": "Sources/PromptyModel/conversation/content_part.swift", + "protocol": false + }, + { + "name": "Message", + "kind": "value", + "group": "conversation", + "source": "Sources/PromptyModel/conversation/message.swift", + "protocol": false + }, + { + "name": "TextPart", + "kind": "value", + "group": "conversation", + "source": "Sources/PromptyModel/conversation/content_part.swift", + "protocol": false + }, + { + "name": "ThreadMarker", + "kind": "value", + "group": "conversation", + "source": "Sources/PromptyModel/conversation/thread_marker.swift", + "protocol": false + }, + { + "name": "ToolCall", + "kind": "value", + "group": "conversation", + "source": "Sources/PromptyModel/conversation/tool_call.swift", + "protocol": false + }, + { + "name": "ToolResult", + "kind": "value", + "group": "conversation", + "source": "Sources/PromptyModel/conversation/tool_result.swift", + "protocol": false + }, + { + "name": "ArrayProperty", + "kind": "value", + "group": "core", + "source": "Sources/PromptyModel/core/property.swift", + "protocol": false + }, + { + "name": "FileNotFoundError", + "kind": "value", + "group": "core", + "source": "Sources/PromptyModel/core/file_not_found_error.swift", + "protocol": false + }, + { + "name": "InvokerError", + "kind": "value", + "group": "core", + "source": "Sources/PromptyModel/core/invoker_error.swift", + "protocol": false + }, + { + "name": "ObjectProperty", + "kind": "value", + "group": "core", + "source": "Sources/PromptyModel/core/property.swift", + "protocol": false + }, + { + "name": "Property", + "kind": "value", + "group": "core", + "source": "Sources/PromptyModel/core/property.swift", + "protocol": false + }, + { + "name": "UnionProperty", + "kind": "value", + "group": "core", + "source": "Sources/PromptyModel/core/property.swift", + "protocol": false + }, + { + "name": "ValidationError", + "kind": "value", + "group": "core", + "source": "Sources/PromptyModel/core/validation_error.swift", + "protocol": false + }, + { + "name": "ValidationResult", + "kind": "value", + "group": "core", + "source": "Sources/PromptyModel/core/validation_result.swift", + "protocol": false + }, + { + "name": "Checkpoint", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/checkpoint.swift", + "protocol": false + }, + { + "name": "CompactionCompletePayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/compaction_complete_payload.swift", + "protocol": false + }, + { + "name": "CompactionFailedPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/compaction_failed_payload.swift", + "protocol": false + }, + { + "name": "CompactionStartPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/compaction_start_payload.swift", + "protocol": false + }, + { + "name": "DoneEventPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/done_event_payload.swift", + "protocol": false + }, + { + "name": "ErrorChunk", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/stream_chunk.swift", + "protocol": false + }, + { + "name": "ErrorEventPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/error_event_payload.swift", + "protocol": false + }, + { + "name": "HarnessContext", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/harness_context.swift", + "protocol": false + }, + { + "name": "HookEndPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/hook_end_payload.swift", + "protocol": false + }, + { + "name": "HookStartPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/hook_start_payload.swift", + "protocol": false + }, + { + "name": "HostToolRequest", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/host_tool_request.swift", + "protocol": false + }, + { + "name": "HostToolResult", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/host_tool_result.swift", + "protocol": false + }, + { + "name": "LlmCompletePayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/llm_complete_payload.swift", + "protocol": false + }, + { + "name": "LlmStartPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/llm_start_payload.swift", + "protocol": false + }, + { + "name": "MessagesUpdatedPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/messages_updated_payload.swift", + "protocol": false + }, + { + "name": "PermissionCompletedPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/permission_completed_payload.swift", + "protocol": false + }, + { + "name": "PermissionDecision", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/permission_decision.swift", + "protocol": false + }, + { + "name": "PermissionRequest", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/permission_request.swift", + "protocol": false + }, + { + "name": "PermissionRequestedPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/permission_requested_payload.swift", + "protocol": false + }, + { + "name": "RedactedField", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/redacted_field.swift", + "protocol": false + }, + { + "name": "RedactionMetadata", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/redaction_metadata.swift", + "protocol": false + }, + { + "name": "RetryPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/retry_payload.swift", + "protocol": false + }, + { + "name": "SessionEndPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/session_end_payload.swift", + "protocol": false + }, + { + "name": "SessionEvent", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/session_event.swift", + "protocol": false + }, + { + "name": "SessionFileRef", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/session_file_ref.swift", + "protocol": false + }, + { + "name": "SessionRef", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/session_ref.swift", + "protocol": false + }, + { + "name": "SessionStartPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/session_start_payload.swift", + "protocol": false + }, + { + "name": "SessionSummary", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/session_summary.swift", + "protocol": false + }, + { + "name": "SessionTrace", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/session_trace.swift", + "protocol": false + }, + { + "name": "SessionWarningPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/session_warning_payload.swift", + "protocol": false + }, + { + "name": "StatusEventPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/status_event_payload.swift", + "protocol": false + }, + { + "name": "StreamChunk", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/stream_chunk.swift", + "protocol": false + }, + { + "name": "TextChunk", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/stream_chunk.swift", + "protocol": false + }, + { + "name": "ThinkingChunk", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/stream_chunk.swift", + "protocol": false + }, + { + "name": "ThinkingEventPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/thinking_event_payload.swift", + "protocol": false + }, + { + "name": "TokenEventPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/token_event_payload.swift", + "protocol": false + }, + { + "name": "ToolCallCompletePayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/tool_call_complete_payload.swift", + "protocol": false + }, + { + "name": "ToolCallStartPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/tool_call_start_payload.swift", + "protocol": false + }, + { + "name": "ToolChunk", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/stream_chunk.swift", + "protocol": false + }, + { + "name": "ToolExecutionCompletePayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/tool_execution_complete_payload.swift", + "protocol": false + }, + { + "name": "ToolExecutionStartPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/tool_execution_start_payload.swift", + "protocol": false + }, + { + "name": "ToolResultPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/tool_result_payload.swift", + "protocol": false + }, + { + "name": "TrajectoryEvent", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/trajectory_event.swift", + "protocol": false + }, + { + "name": "TurnEndPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/turn_end_payload.swift", + "protocol": false + }, + { + "name": "TurnEvent", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/turn_event.swift", + "protocol": false + }, + { + "name": "TurnStartPayload", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/turn_start_payload.swift", + "protocol": false + }, + { + "name": "TurnSummary", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/turn_summary.swift", + "protocol": false + }, + { + "name": "TurnTrace", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/turn_trace.swift", + "protocol": false + }, + { + "name": "UsageChunk", + "kind": "value", + "group": "events", + "source": "Sources/PromptyModel/events/stream_chunk.swift", + "protocol": false + }, + { + "name": "MemoryEntry", + "kind": "value", + "group": "memory", + "source": "Sources/PromptyModel/memory/memory_entry.swift", + "protocol": false + }, + { + "name": "MemoryStore", + "kind": "value", + "group": "memory", + "source": "Sources/PromptyModel/memory/memory_store.swift", + "protocol": false + }, + { + "name": "AiResourceInfo", + "kind": "value", + "group": "model", + "source": "Sources/PromptyModel/model/ai_resource_info.swift", + "protocol": false + }, + { + "name": "InvocationUsage", + "kind": "value", + "group": "model", + "source": "Sources/PromptyModel/model/invocation_usage.swift", + "protocol": false + }, + { + "name": "Model", + "kind": "value", + "group": "model", + "source": "Sources/PromptyModel/model/model.swift", + "protocol": false + }, + { + "name": "ModelInfo", + "kind": "value", + "group": "model", + "source": "Sources/PromptyModel/model/model_info.swift", + "protocol": false + }, + { + "name": "ModelLister", + "kind": "type", + "group": "model", + "source": "Sources/PromptyModel/model/model_lister.swift", + "protocol": true + }, + { + "name": "ModelOptions", + "kind": "value", + "group": "model", + "source": "Sources/PromptyModel/model/model_options.swift", + "protocol": false + }, + { + "name": "ProjectInfo", + "kind": "value", + "group": "model", + "source": "Sources/PromptyModel/model/project_info.swift", + "protocol": false + }, + { + "name": "SubscriptionInfo", + "kind": "value", + "group": "model", + "source": "Sources/PromptyModel/model/subscription_info.swift", + "protocol": false + }, + { + "name": "TokenUsage", + "kind": "value", + "group": "model", + "source": "Sources/PromptyModel/model/token_usage.swift", + "protocol": false + }, + { + "name": "CheckpointStore", + "kind": "type", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/checkpoint_store.swift", + "protocol": true + }, + { + "name": "CompactionConfig", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/compaction_config.swift", + "protocol": false + }, + { + "name": "ContextCandidate", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/context_candidate.swift", + "protocol": false + }, + { + "name": "ContextRequest", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/context_request.swift", + "protocol": false + }, + { + "name": "DelegatedStateReference", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/delegated_state_reference.swift", + "protocol": false + }, + { + "name": "EngineCheckpoint", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/engine_checkpoint.swift", + "protocol": false + }, + { + "name": "EngineEvent", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/engine_event.swift", + "protocol": false + }, + { + "name": "EnginePermissionDecision", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/engine_permission_decision.swift", + "protocol": false + }, + { + "name": "EventJournalWriter", + "kind": "type", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/event_journal_writer.swift", + "protocol": true + }, + { + "name": "EventSink", + "kind": "type", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/event_sink.swift", + "protocol": true + }, + { + "name": "Executor", + "kind": "type", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/executor.swift", + "protocol": true + }, + { + "name": "FinalOutputPolicyRequest", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/final_output_policy_request.swift", + "protocol": false + }, + { + "name": "FinalOutputPolicyResult", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/final_output_policy_result.swift", + "protocol": false + }, + { + "name": "HostPolicyRequest", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/host_policy_request.swift", + "protocol": false + }, + { + "name": "HostPolicyResult", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/host_policy_result.swift", + "protocol": false + }, + { + "name": "HostToolExecutor", + "kind": "type", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/host_tool_executor.swift", + "protocol": true + }, + { + "name": "InvocationContextDecision", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/invocation_context_decision.swift", + "protocol": false + }, + { + "name": "InvocationContextState", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/invocation_context_state.swift", + "protocol": false + }, + { + "name": "ModelInvocationContextSnapshot", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/model_invocation_context_snapshot.swift", + "protocol": false + }, + { + "name": "ModelInvocationRequest", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/model_invocation_request.swift", + "protocol": false + }, + { + "name": "ModelInvocationResponse", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/model_invocation_response.swift", + "protocol": false + }, + { + "name": "ModelReconciliationState", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/model_reconciliation_state.swift", + "protocol": false + }, + { + "name": "ModelToolRequest", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/model_tool_request.swift", + "protocol": false + }, + { + "name": "ModelToolResult", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/model_tool_result.swift", + "protocol": false + }, + { + "name": "Parser", + "kind": "type", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/parser.swift", + "protocol": true + }, + { + "name": "PermissionResolver", + "kind": "type", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/permission_resolver.swift", + "protocol": true + }, + { + "name": "Processor", + "kind": "type", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/processor.swift", + "protocol": true + }, + { + "name": "Renderer", + "kind": "type", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/renderer.swift", + "protocol": true + }, + { + "name": "ReplayJournalRecord", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/replay_journal_record.swift", + "protocol": false + }, + { + "name": "ReplayMismatch", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/replay_mismatch.swift", + "protocol": false + }, + { + "name": "ReplayVerificationRequest", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/replay_verification_request.swift", + "protocol": false + }, + { + "name": "ReplayVerificationResult", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/replay_verification_result.swift", + "protocol": false + }, + { + "name": "ResumeContext", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/resume_context.swift", + "protocol": false + }, + { + "name": "RetryPolicyRequest", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/retry_policy_request.swift", + "protocol": false + }, + { + "name": "RunTurnRequest", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/run_turn_request.swift", + "protocol": false + }, + { + "name": "RunTurnResult", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/run_turn_result.swift", + "protocol": false + }, + { + "name": "TurnCommit", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/turn_commit.swift", + "protocol": false + }, + { + "name": "TurnEngineResult", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/turn_engine_result.swift", + "protocol": false + }, + { + "name": "TurnModelRequest", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/turn_model_request.swift", + "protocol": false + }, + { + "name": "TurnModelResponse", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/turn_model_response.swift", + "protocol": false + }, + { + "name": "TurnOptions", + "kind": "value", + "group": "pipeline", + "source": "Sources/PromptyModel/pipeline/turn_options.swift", + "protocol": false + }, + { + "name": "StreamOptions", + "kind": "value", + "group": "streaming", + "source": "Sources/PromptyModel/streaming/stream_options.swift", + "protocol": false + }, + { + "name": "FormatConfig", + "kind": "value", + "group": "template", + "source": "Sources/PromptyModel/template/format_config.swift", + "protocol": false + }, + { + "name": "ParserConfig", + "kind": "value", + "group": "template", + "source": "Sources/PromptyModel/template/parser_config.swift", + "protocol": false + }, + { + "name": "Template", + "kind": "value", + "group": "template", + "source": "Sources/PromptyModel/template/template.swift", + "protocol": false + }, + { + "name": "Binding", + "kind": "value", + "group": "tools", + "source": "Sources/PromptyModel/tools/binding.swift", + "protocol": false + }, + { + "name": "CustomTool", + "kind": "value", + "group": "tools", + "source": "Sources/PromptyModel/tools/tool.swift", + "protocol": false + }, + { + "name": "FunctionTool", + "kind": "value", + "group": "tools", + "source": "Sources/PromptyModel/tools/tool.swift", + "protocol": false + }, + { + "name": "McpApprovalMode", + "kind": "value", + "group": "tools", + "source": "Sources/PromptyModel/tools/mcp_approval_mode.swift", + "protocol": false + }, + { + "name": "McpTool", + "kind": "value", + "group": "tools", + "source": "Sources/PromptyModel/tools/tool.swift", + "protocol": false + }, + { + "name": "OpenApiTool", + "kind": "value", + "group": "tools", + "source": "Sources/PromptyModel/tools/tool.swift", + "protocol": false + }, + { + "name": "PromptyTool", + "kind": "value", + "group": "tools", + "source": "Sources/PromptyModel/tools/tool.swift", + "protocol": false + }, + { + "name": "Tool", + "kind": "value", + "group": "tools", + "source": "Sources/PromptyModel/tools/tool.swift", + "protocol": false + }, + { + "name": "ToolContext", + "kind": "value", + "group": "tools", + "source": "Sources/PromptyModel/tools/tool_context.swift", + "protocol": false + }, + { + "name": "ToolDispatchResult", + "kind": "value", + "group": "tools", + "source": "Sources/PromptyModel/tools/tool_dispatch_result.swift", + "protocol": false + }, + { + "name": "TraceFile", + "kind": "value", + "group": "tracing", + "source": "Sources/PromptyModel/tracing/trace_file.swift", + "protocol": false + }, + { + "name": "TraceSpan", + "kind": "value", + "group": "tracing", + "source": "Sources/PromptyModel/tracing/trace_span.swift", + "protocol": false + }, + { + "name": "TraceTime", + "kind": "value", + "group": "tracing", + "source": "Sources/PromptyModel/tracing/trace_time.swift", + "protocol": false + }, + { + "name": "AnthropicImageBlock", + "kind": "value", + "group": "wire", + "source": "Sources/PromptyModel/wire/anthropic_image_block.swift", + "protocol": false + }, + { + "name": "AnthropicImageSource", + "kind": "value", + "group": "wire", + "source": "Sources/PromptyModel/wire/anthropic_image_source.swift", + "protocol": false + }, + { + "name": "AnthropicMessagesRequest", + "kind": "value", + "group": "wire", + "source": "Sources/PromptyModel/wire/anthropic_messages_request.swift", + "protocol": false + }, + { + "name": "AnthropicMessagesResponse", + "kind": "value", + "group": "wire", + "source": "Sources/PromptyModel/wire/anthropic_messages_response.swift", + "protocol": false + }, + { + "name": "AnthropicTextBlock", + "kind": "value", + "group": "wire", + "source": "Sources/PromptyModel/wire/anthropic_text_block.swift", + "protocol": false + }, + { + "name": "AnthropicToolDefinition", + "kind": "value", + "group": "wire", + "source": "Sources/PromptyModel/wire/anthropic_tool_definition.swift", + "protocol": false + }, + { + "name": "AnthropicToolResultBlock", + "kind": "value", + "group": "wire", + "source": "Sources/PromptyModel/wire/anthropic_tool_result_block.swift", + "protocol": false + }, + { + "name": "AnthropicToolUseBlock", + "kind": "value", + "group": "wire", + "source": "Sources/PromptyModel/wire/anthropic_tool_use_block.swift", + "protocol": false + }, + { + "name": "AnthropicUsage", + "kind": "value", + "group": "wire", + "source": "Sources/PromptyModel/wire/anthropic_usage.swift", + "protocol": false + }, + { + "name": "AnthropicWireMessage", + "kind": "value", + "group": "wire", + "source": "Sources/PromptyModel/wire/anthropic_wire_message.swift", + "protocol": false + } + ], + "groups": [ + { + "name": "agent", + "exports": [ + "GuardrailResult", + "Prompty" + ], + "modules": [ + "guardrail_result.swift", + "prompty.swift" + ] + }, + { + "name": "connection", + "exports": [ + "AnonymousConnection", + "ApiKeyConnection", + "AuthorizationCodeFlow", + "Connection", + "DeviceAuthorization", + "FoundryConnection", + "OAuthConnection", + "OAuthToken", + "ReferenceConnection", + "RemoteConnection" + ], + "modules": [ + "authorization_code_flow.swift", + "connection.swift", + "device_authorization.swift", + "o_auth_token.swift" + ] + }, + { + "name": "conversation", + "exports": [ + "AudioPart", + "ContentPart", + "FilePart", + "ImagePart", + "Message", + "TextPart", + "ThreadMarker", + "ToolCall", + "ToolResult" + ], + "modules": [ + "content_part.swift", + "message.swift", + "thread_marker.swift", + "tool_call.swift", + "tool_result.swift" + ] + }, + { + "name": "core", + "exports": [ + "ArrayProperty", + "FileNotFoundError", + "InvokerError", + "ObjectProperty", + "Property", + "UnionProperty", + "ValidationError", + "ValidationResult" + ], + "modules": [ + "file_not_found_error.swift", + "invoker_error.swift", + "property.swift", + "validation_error.swift", + "validation_result.swift" + ] + }, + { + "name": "events", + "exports": [ + "Checkpoint", + "CompactionCompletePayload", + "CompactionFailedPayload", + "CompactionStartPayload", + "DoneEventPayload", + "ErrorChunk", + "ErrorEventPayload", + "HarnessContext", + "HookEndPayload", + "HookStartPayload", + "HostToolRequest", + "HostToolResult", + "LlmCompletePayload", + "LlmStartPayload", + "MessagesUpdatedPayload", + "PermissionCompletedPayload", + "PermissionDecision", + "PermissionRequest", + "PermissionRequestedPayload", + "RedactedField", + "RedactionMetadata", + "RetryPayload", + "SessionEndPayload", + "SessionEvent", + "SessionFileRef", + "SessionRef", + "SessionStartPayload", + "SessionSummary", + "SessionTrace", + "SessionWarningPayload", + "StatusEventPayload", + "StreamChunk", + "TextChunk", + "ThinkingChunk", + "ThinkingEventPayload", + "TokenEventPayload", + "ToolCallCompletePayload", + "ToolCallStartPayload", + "ToolChunk", + "ToolExecutionCompletePayload", + "ToolExecutionStartPayload", + "ToolResultPayload", + "TrajectoryEvent", + "TurnEndPayload", + "TurnEvent", + "TurnStartPayload", + "TurnSummary", + "TurnTrace", + "UsageChunk" + ], + "modules": [ + "checkpoint.swift", + "compaction_complete_payload.swift", + "compaction_failed_payload.swift", + "compaction_start_payload.swift", + "done_event_payload.swift", + "error_event_payload.swift", + "harness_context.swift", + "hook_end_payload.swift", + "hook_start_payload.swift", + "host_tool_request.swift", + "host_tool_result.swift", + "llm_complete_payload.swift", + "llm_start_payload.swift", + "messages_updated_payload.swift", + "permission_completed_payload.swift", + "permission_decision.swift", + "permission_request.swift", + "permission_requested_payload.swift", + "redacted_field.swift", + "redaction_metadata.swift", + "retry_payload.swift", + "session_end_payload.swift", + "session_event.swift", + "session_file_ref.swift", + "session_ref.swift", + "session_start_payload.swift", + "session_summary.swift", + "session_trace.swift", + "session_warning_payload.swift", + "status_event_payload.swift", + "stream_chunk.swift", + "thinking_event_payload.swift", + "token_event_payload.swift", + "tool_call_complete_payload.swift", + "tool_call_start_payload.swift", + "tool_execution_complete_payload.swift", + "tool_execution_start_payload.swift", + "tool_result_payload.swift", + "trajectory_event.swift", + "turn_end_payload.swift", + "turn_event.swift", + "turn_start_payload.swift", + "turn_summary.swift", + "turn_trace.swift" + ] + }, + { + "name": "memory", + "exports": [ + "MemoryEntry", + "MemoryStore" + ], + "modules": [ + "memory_entry.swift", + "memory_store.swift" + ] + }, + { + "name": "model", + "exports": [ + "AiResourceInfo", + "InvocationUsage", + "Model", + "ModelInfo", + "ModelLister", + "ModelOptions", + "ProjectInfo", + "SubscriptionInfo", + "TokenUsage" + ], + "modules": [ + "ai_resource_info.swift", + "invocation_usage.swift", + "model.swift", + "model_info.swift", + "model_lister.swift", + "model_options.swift", + "project_info.swift", + "subscription_info.swift", + "token_usage.swift" + ] + }, + { + "name": "pipeline", + "exports": [ + "CheckpointStore", + "CompactionConfig", + "ContextCandidate", + "ContextRequest", + "DelegatedStateReference", + "EngineCheckpoint", + "EngineEvent", + "EnginePermissionDecision", + "EventJournalWriter", + "EventSink", + "Executor", + "FinalOutputPolicyRequest", + "FinalOutputPolicyResult", + "HostPolicyRequest", + "HostPolicyResult", + "HostToolExecutor", + "InvocationContextDecision", + "InvocationContextState", + "ModelInvocationContextSnapshot", + "ModelInvocationRequest", + "ModelInvocationResponse", + "ModelReconciliationState", + "ModelToolRequest", + "ModelToolResult", + "Parser", + "PermissionResolver", + "Processor", + "Renderer", + "ReplayJournalRecord", + "ReplayMismatch", + "ReplayVerificationRequest", + "ReplayVerificationResult", + "ResumeContext", + "RetryPolicyRequest", + "RunTurnRequest", + "RunTurnResult", + "TurnCommit", + "TurnEngineResult", + "TurnModelRequest", + "TurnModelResponse", + "TurnOptions" + ], + "modules": [ + "checkpoint_store.swift", + "compaction_config.swift", + "context_candidate.swift", + "context_request.swift", + "delegated_state_reference.swift", + "engine_checkpoint.swift", + "engine_event.swift", + "engine_permission_decision.swift", + "event_journal_writer.swift", + "event_sink.swift", + "executor.swift", + "final_output_policy_request.swift", + "final_output_policy_result.swift", + "host_policy_request.swift", + "host_policy_result.swift", + "host_tool_executor.swift", + "invocation_context_decision.swift", + "invocation_context_state.swift", + "model_invocation_context_snapshot.swift", + "model_invocation_request.swift", + "model_invocation_response.swift", + "model_reconciliation_state.swift", + "model_tool_request.swift", + "model_tool_result.swift", + "parser.swift", + "permission_resolver.swift", + "processor.swift", + "renderer.swift", + "replay_journal_record.swift", + "replay_mismatch.swift", + "replay_verification_request.swift", + "replay_verification_result.swift", + "resume_context.swift", + "retry_policy_request.swift", + "run_turn_request.swift", + "run_turn_result.swift", + "turn_commit.swift", + "turn_engine_result.swift", + "turn_model_request.swift", + "turn_model_response.swift", + "turn_options.swift" + ] + }, + { + "name": "streaming", + "exports": [ + "StreamOptions" + ], + "modules": [ + "stream_options.swift" + ] + }, + { + "name": "template", + "exports": [ + "FormatConfig", + "ParserConfig", + "Template" + ], + "modules": [ + "format_config.swift", + "parser_config.swift", + "template.swift" + ] + }, + { + "name": "tools", + "exports": [ + "Binding", + "CustomTool", + "FunctionTool", + "McpApprovalMode", + "McpTool", + "OpenApiTool", + "PromptyTool", + "Tool", + "ToolContext", + "ToolDispatchResult" + ], + "modules": [ + "binding.swift", + "mcp_approval_mode.swift", + "tool.swift", + "tool_context.swift", + "tool_dispatch_result.swift" + ] + }, + { + "name": "tracing", + "exports": [ + "TraceFile", + "TraceSpan", + "TraceTime" + ], + "modules": [ + "trace_file.swift", + "trace_span.swift", + "trace_time.swift" + ] + }, + { + "name": "wire", + "exports": [ + "AnthropicImageBlock", + "AnthropicImageSource", + "AnthropicMessagesRequest", + "AnthropicMessagesResponse", + "AnthropicTextBlock", + "AnthropicToolDefinition", + "AnthropicToolResultBlock", + "AnthropicToolUseBlock", + "AnthropicUsage", + "AnthropicWireMessage" + ], + "modules": [ + "anthropic_image_block.swift", + "anthropic_image_source.swift", + "anthropic_messages_request.swift", + "anthropic_messages_response.swift", + "anthropic_text_block.swift", + "anthropic_tool_definition.swift", + "anthropic_tool_result_block.swift", + "anthropic_tool_use_block.swift", + "anthropic_usage.swift", + "anthropic_wire_message.swift" + ] + } + ], + "protocols": [ + { + "name": "ModelLister", + "group": "model", + "symbol": "ModelLister", + "source": "Sources/PromptyModel/model/model_lister.swift", + "methods": [ + { + "name": "listModels", + "returns": "ModelInfo[]", + "params": { + "connection": "unknown" + }, + "optional": false, + "sync": false + } + ] + }, + { + "name": "CheckpointStore", + "group": "pipeline", + "symbol": "CheckpointStore", + "source": "Sources/PromptyModel/pipeline/checkpoint_store.swift", + "methods": [ + { + "name": "listCheckpoints", + "returns": "Checkpoint[]", + "params": { + "sessionId": "string" + }, + "optional": false, + "sync": false + }, + { + "name": "load", + "returns": "Checkpoint?", + "params": { + "checkpointId": "string", + "sessionId": "string" + }, + "optional": false, + "sync": false + }, + { + "name": "save", + "returns": "Checkpoint", + "params": { + "checkpoint": "Checkpoint" + }, + "optional": false, + "sync": false + } + ] + }, + { + "name": "EventJournalWriter", + "group": "pipeline", + "symbol": "EventJournalWriter", + "source": "Sources/PromptyModel/pipeline/event_journal_writer.swift", + "methods": [ + { + "name": "appendSession", + "returns": "boolean", + "params": { + "sessionEvent": "SessionEvent" + }, + "optional": false, + "sync": true + }, + { + "name": "appendTurn", + "returns": "boolean", + "params": { + "turnEvent": "TurnEvent" + }, + "optional": false, + "sync": true + }, + { + "name": "close", + "returns": "boolean", + "params": { + "summary": "SessionSummary?" + }, + "optional": false, + "sync": true + } + ] + }, + { + "name": "EventSink", + "group": "pipeline", + "symbol": "EventSink", + "source": "Sources/PromptyModel/pipeline/event_sink.swift", + "methods": [ + { + "name": "emitSession", + "returns": "boolean", + "params": { + "sessionEvent": "SessionEvent" + }, + "optional": false, + "sync": true + }, + { + "name": "emitTurn", + "returns": "boolean", + "params": { + "turnEvent": "TurnEvent" + }, + "optional": false, + "sync": true + } + ] + }, + { + "name": "Executor", + "group": "pipeline", + "symbol": "Executor", + "source": "Sources/PromptyModel/pipeline/executor.swift", + "methods": [ + { + "name": "execute", + "returns": "unknown", + "params": { + "agent": "Prompty", + "messages": "Message[]" + }, + "optional": false, + "sync": false + }, + { + "name": "executeStream", + "returns": "unknown", + "params": { + "agent": "Prompty", + "messages": "Message[]" + }, + "optional": true, + "sync": false + }, + { + "name": "formatToolMessages", + "returns": "Message[]", + "params": { + "rawResponse": "unknown", + "textContent": "string?", + "toolCalls": "ToolCall[]", + "toolResults": "string[]" + }, + "optional": false, + "sync": true + } + ] + }, + { + "name": "HostToolExecutor", + "group": "pipeline", + "symbol": "HostToolExecutor", + "source": "Sources/PromptyModel/pipeline/host_tool_executor.swift", + "methods": [ + { + "name": "execute", + "returns": "HostToolResult", + "params": { + "request": "HostToolRequest" + }, + "optional": false, + "sync": false + } + ] + }, + { + "name": "Parser", + "group": "pipeline", + "symbol": "Parser", + "source": "Sources/PromptyModel/pipeline/parser.swift", + "methods": [ + { + "name": "parse", + "returns": "Message[]", + "params": { + "agent": "Prompty", + "context": "Record?", + "rendered": "string" + }, + "optional": false, + "sync": false + }, + { + "name": "preRender", + "returns": "unknown?", + "params": { + "template": "string" + }, + "optional": true, + "sync": true + } + ] + }, + { + "name": "PermissionResolver", + "group": "pipeline", + "symbol": "PermissionResolver", + "source": "Sources/PromptyModel/pipeline/permission_resolver.swift", + "methods": [ + { + "name": "request", + "returns": "PermissionDecision", + "params": { + "request": "PermissionRequest" + }, + "optional": false, + "sync": false + } + ] + }, + { + "name": "Processor", + "group": "pipeline", + "symbol": "Processor", + "source": "Sources/PromptyModel/pipeline/processor.swift", + "methods": [ + { + "name": "process", + "returns": "unknown", + "params": { + "agent": "Prompty", + "response": "unknown" + }, + "optional": false, + "sync": false + }, + { + "name": "processStream", + "returns": "unknown", + "params": { + "stream": "unknown" + }, + "optional": true, + "sync": false + } + ] + }, + { + "name": "Renderer", + "group": "pipeline", + "symbol": "Renderer", + "source": "Sources/PromptyModel/pipeline/renderer.swift", + "methods": [ + { + "name": "render", + "returns": "string", + "params": { + "agent": "Prompty", + "inputs": "Record", + "template": "string" + }, + "optional": false, + "sync": false + } + ] + } + ], + "modules": [ + "Sources/PromptyModel/agent/guardrail_result.swift", + "Sources/PromptyModel/agent/prompty.swift", + "Sources/PromptyModel/connection/authorization_code_flow.swift", + "Sources/PromptyModel/connection/connection.swift", + "Sources/PromptyModel/connection/device_authorization.swift", + "Sources/PromptyModel/connection/o_auth_token.swift", + "Sources/PromptyModel/conversation/content_part.swift", + "Sources/PromptyModel/conversation/message.swift", + "Sources/PromptyModel/conversation/thread_marker.swift", + "Sources/PromptyModel/conversation/tool_call.swift", + "Sources/PromptyModel/conversation/tool_result.swift", + "Sources/PromptyModel/core/file_not_found_error.swift", + "Sources/PromptyModel/core/invoker_error.swift", + "Sources/PromptyModel/core/property.swift", + "Sources/PromptyModel/core/validation_error.swift", + "Sources/PromptyModel/core/validation_result.swift", + "Sources/PromptyModel/events/checkpoint.swift", + "Sources/PromptyModel/events/compaction_complete_payload.swift", + "Sources/PromptyModel/events/compaction_failed_payload.swift", + "Sources/PromptyModel/events/compaction_start_payload.swift", + "Sources/PromptyModel/events/done_event_payload.swift", + "Sources/PromptyModel/events/error_event_payload.swift", + "Sources/PromptyModel/events/harness_context.swift", + "Sources/PromptyModel/events/hook_end_payload.swift", + "Sources/PromptyModel/events/hook_start_payload.swift", + "Sources/PromptyModel/events/host_tool_request.swift", + "Sources/PromptyModel/events/host_tool_result.swift", + "Sources/PromptyModel/events/llm_complete_payload.swift", + "Sources/PromptyModel/events/llm_start_payload.swift", + "Sources/PromptyModel/events/messages_updated_payload.swift", + "Sources/PromptyModel/events/permission_completed_payload.swift", + "Sources/PromptyModel/events/permission_decision.swift", + "Sources/PromptyModel/events/permission_request.swift", + "Sources/PromptyModel/events/permission_requested_payload.swift", + "Sources/PromptyModel/events/redacted_field.swift", + "Sources/PromptyModel/events/redaction_metadata.swift", + "Sources/PromptyModel/events/retry_payload.swift", + "Sources/PromptyModel/events/session_end_payload.swift", + "Sources/PromptyModel/events/session_event.swift", + "Sources/PromptyModel/events/session_file_ref.swift", + "Sources/PromptyModel/events/session_ref.swift", + "Sources/PromptyModel/events/session_start_payload.swift", + "Sources/PromptyModel/events/session_summary.swift", + "Sources/PromptyModel/events/session_trace.swift", + "Sources/PromptyModel/events/session_warning_payload.swift", + "Sources/PromptyModel/events/status_event_payload.swift", + "Sources/PromptyModel/events/stream_chunk.swift", + "Sources/PromptyModel/events/thinking_event_payload.swift", + "Sources/PromptyModel/events/token_event_payload.swift", + "Sources/PromptyModel/events/tool_call_complete_payload.swift", + "Sources/PromptyModel/events/tool_call_start_payload.swift", + "Sources/PromptyModel/events/tool_execution_complete_payload.swift", + "Sources/PromptyModel/events/tool_execution_start_payload.swift", + "Sources/PromptyModel/events/tool_result_payload.swift", + "Sources/PromptyModel/events/trajectory_event.swift", + "Sources/PromptyModel/events/turn_end_payload.swift", + "Sources/PromptyModel/events/turn_event.swift", + "Sources/PromptyModel/events/turn_start_payload.swift", + "Sources/PromptyModel/events/turn_summary.swift", + "Sources/PromptyModel/events/turn_trace.swift", + "Sources/PromptyModel/memory/memory_entry.swift", + "Sources/PromptyModel/memory/memory_store.swift", + "Sources/PromptyModel/model/ai_resource_info.swift", + "Sources/PromptyModel/model/invocation_usage.swift", + "Sources/PromptyModel/model/model.swift", + "Sources/PromptyModel/model/model_info.swift", + "Sources/PromptyModel/model/model_lister.swift", + "Sources/PromptyModel/model/model_options.swift", + "Sources/PromptyModel/model/project_info.swift", + "Sources/PromptyModel/model/subscription_info.swift", + "Sources/PromptyModel/model/token_usage.swift", + "Sources/PromptyModel/pipeline/checkpoint_store.swift", + "Sources/PromptyModel/pipeline/compaction_config.swift", + "Sources/PromptyModel/pipeline/context_candidate.swift", + "Sources/PromptyModel/pipeline/context_request.swift", + "Sources/PromptyModel/pipeline/delegated_state_reference.swift", + "Sources/PromptyModel/pipeline/engine_checkpoint.swift", + "Sources/PromptyModel/pipeline/engine_event.swift", + "Sources/PromptyModel/pipeline/engine_permission_decision.swift", + "Sources/PromptyModel/pipeline/event_journal_writer.swift", + "Sources/PromptyModel/pipeline/event_sink.swift", + "Sources/PromptyModel/pipeline/executor.swift", + "Sources/PromptyModel/pipeline/final_output_policy_request.swift", + "Sources/PromptyModel/pipeline/final_output_policy_result.swift", + "Sources/PromptyModel/pipeline/host_policy_request.swift", + "Sources/PromptyModel/pipeline/host_policy_result.swift", + "Sources/PromptyModel/pipeline/host_tool_executor.swift", + "Sources/PromptyModel/pipeline/invocation_context_decision.swift", + "Sources/PromptyModel/pipeline/invocation_context_state.swift", + "Sources/PromptyModel/pipeline/model_invocation_context_snapshot.swift", + "Sources/PromptyModel/pipeline/model_invocation_request.swift", + "Sources/PromptyModel/pipeline/model_invocation_response.swift", + "Sources/PromptyModel/pipeline/model_reconciliation_state.swift", + "Sources/PromptyModel/pipeline/model_tool_request.swift", + "Sources/PromptyModel/pipeline/model_tool_result.swift", + "Sources/PromptyModel/pipeline/parser.swift", + "Sources/PromptyModel/pipeline/permission_resolver.swift", + "Sources/PromptyModel/pipeline/processor.swift", + "Sources/PromptyModel/pipeline/renderer.swift", + "Sources/PromptyModel/pipeline/replay_journal_record.swift", + "Sources/PromptyModel/pipeline/replay_mismatch.swift", + "Sources/PromptyModel/pipeline/replay_verification_request.swift", + "Sources/PromptyModel/pipeline/replay_verification_result.swift", + "Sources/PromptyModel/pipeline/resume_context.swift", + "Sources/PromptyModel/pipeline/retry_policy_request.swift", + "Sources/PromptyModel/pipeline/run_turn_request.swift", + "Sources/PromptyModel/pipeline/run_turn_result.swift", + "Sources/PromptyModel/pipeline/turn_commit.swift", + "Sources/PromptyModel/pipeline/turn_engine_result.swift", + "Sources/PromptyModel/pipeline/turn_model_request.swift", + "Sources/PromptyModel/pipeline/turn_model_response.swift", + "Sources/PromptyModel/pipeline/turn_options.swift", + "Sources/PromptyModel/streaming/stream_options.swift", + "Sources/PromptyModel/template/format_config.swift", + "Sources/PromptyModel/template/parser_config.swift", + "Sources/PromptyModel/template/template.swift", + "Sources/PromptyModel/tools/binding.swift", + "Sources/PromptyModel/tools/mcp_approval_mode.swift", + "Sources/PromptyModel/tools/tool.swift", + "Sources/PromptyModel/tools/tool_context.swift", + "Sources/PromptyModel/tools/tool_dispatch_result.swift", + "Sources/PromptyModel/tracing/trace_file.swift", + "Sources/PromptyModel/tracing/trace_span.swift", + "Sources/PromptyModel/tracing/trace_time.swift", + "Sources/PromptyModel/wire/anthropic_image_block.swift", + "Sources/PromptyModel/wire/anthropic_image_source.swift", + "Sources/PromptyModel/wire/anthropic_messages_request.swift", + "Sources/PromptyModel/wire/anthropic_messages_response.swift", + "Sources/PromptyModel/wire/anthropic_text_block.swift", + "Sources/PromptyModel/wire/anthropic_tool_definition.swift", + "Sources/PromptyModel/wire/anthropic_tool_result_block.swift", + "Sources/PromptyModel/wire/anthropic_tool_use_block.swift", + "Sources/PromptyModel/wire/anthropic_usage.swift", + "Sources/PromptyModel/wire/anthropic_wire_message.swift" + ] + }, { "target": "typescript", "outputRoot": "../runtime/typescript/packages/core/src/model", diff --git a/schema/tsp-output/.typra-generated/hydration-seams.json b/schema/tsp-output/.typra-generated/hydration-seams.json index aee225062..28ea6b6da 100644 --- a/schema/tsp-output/.typra-generated/hydration-seams.json +++ b/schema/tsp-output/.typra-generated/hydration-seams.json @@ -404,6 +404,86 @@ "generatedSource": "pipeline::renderer", "seamKind": "protocol-adapter" }, + { + "contract": "ModelLister", + "target": "swift", + "group": "model", + "symbol": "ModelLister", + "generatedSource": "Sources/PromptyModel/model/model_lister.swift", + "seamKind": "protocol-adapter" + }, + { + "contract": "CheckpointStore", + "target": "swift", + "group": "pipeline", + "symbol": "CheckpointStore", + "generatedSource": "Sources/PromptyModel/pipeline/checkpoint_store.swift", + "seamKind": "protocol-adapter" + }, + { + "contract": "EventJournalWriter", + "target": "swift", + "group": "pipeline", + "symbol": "EventJournalWriter", + "generatedSource": "Sources/PromptyModel/pipeline/event_journal_writer.swift", + "seamKind": "protocol-adapter" + }, + { + "contract": "EventSink", + "target": "swift", + "group": "pipeline", + "symbol": "EventSink", + "generatedSource": "Sources/PromptyModel/pipeline/event_sink.swift", + "seamKind": "protocol-adapter" + }, + { + "contract": "Executor", + "target": "swift", + "group": "pipeline", + "symbol": "Executor", + "generatedSource": "Sources/PromptyModel/pipeline/executor.swift", + "seamKind": "protocol-adapter" + }, + { + "contract": "HostToolExecutor", + "target": "swift", + "group": "pipeline", + "symbol": "HostToolExecutor", + "generatedSource": "Sources/PromptyModel/pipeline/host_tool_executor.swift", + "seamKind": "protocol-adapter" + }, + { + "contract": "Parser", + "target": "swift", + "group": "pipeline", + "symbol": "Parser", + "generatedSource": "Sources/PromptyModel/pipeline/parser.swift", + "seamKind": "protocol-adapter" + }, + { + "contract": "PermissionResolver", + "target": "swift", + "group": "pipeline", + "symbol": "PermissionResolver", + "generatedSource": "Sources/PromptyModel/pipeline/permission_resolver.swift", + "seamKind": "protocol-adapter" + }, + { + "contract": "Processor", + "target": "swift", + "group": "pipeline", + "symbol": "Processor", + "generatedSource": "Sources/PromptyModel/pipeline/processor.swift", + "seamKind": "protocol-adapter" + }, + { + "contract": "Renderer", + "target": "swift", + "group": "pipeline", + "symbol": "Renderer", + "generatedSource": "Sources/PromptyModel/pipeline/renderer.swift", + "seamKind": "protocol-adapter" + }, { "contract": "ModelLister", "target": "typescript", diff --git a/schema/tsp-output/.typra-generated/manifest.json b/schema/tsp-output/.typra-generated/manifest.json index 4581f6480..537727bc8 100644 --- a/schema/tsp-output/.typra-generated/manifest.json +++ b/schema/tsp-output/.typra-generated/manifest.json @@ -5843,6 +5843,686 @@ "path": "../runtime/rust/prompty/tests/model/wire/mod.rs", "marker": true }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Package.swift", + "marker": false + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/agent/guardrail_result.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/agent/prompty.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/connection/authorization_code_flow.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/connection/connection.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/connection/device_authorization.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/connection/o_auth_token.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/conversation/content_part.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/conversation/message.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/conversation/thread_marker.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/conversation/tool_call.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/conversation/tool_result.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/core/file_not_found_error.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/core/invoker_error.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/core/property.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/core/validation_error.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/core/validation_result.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/checkpoint.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/compaction_complete_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/compaction_failed_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/compaction_start_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/done_event_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/error_event_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/harness_context.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/hook_end_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/hook_start_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/host_tool_request.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/host_tool_result.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/llm_complete_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/llm_start_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/messages_updated_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/permission_completed_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/permission_decision.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/permission_request.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/permission_requested_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/redacted_field.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/redaction_metadata.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/retry_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/session_end_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/session_event.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/session_file_ref.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/session_ref.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/session_start_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/session_summary.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/session_trace.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/session_warning_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/status_event_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/stream_chunk.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/thinking_event_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/token_event_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/tool_call_complete_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/tool_call_start_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/tool_execution_complete_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/tool_execution_start_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/tool_result_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/trajectory_event.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/turn_end_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/turn_event.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/turn_start_payload.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/turn_summary.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/events/turn_trace.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/memory/memory_entry.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/memory/memory_store.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/model/ai_resource_info.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/model/invocation_usage.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/model/model_info.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/model/model_lister.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/model/model_options.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/model/model.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/model/project_info.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/model/subscription_info.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/model/token_usage.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/checkpoint_store.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/compaction_config.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/context_candidate.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/context_request.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/delegated_state_reference.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/engine_checkpoint.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/engine_event.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/engine_permission_decision.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/event_journal_writer.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/event_sink.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/executor.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/final_output_policy_request.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/final_output_policy_result.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/host_policy_request.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/host_policy_result.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/host_tool_executor.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/invocation_context_decision.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/invocation_context_state.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_invocation_context_snapshot.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_invocation_request.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_invocation_response.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_reconciliation_state.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_tool_request.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/model_tool_result.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/parser.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/permission_resolver.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/processor.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/renderer.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_journal_record.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_mismatch.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_verification_request.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/replay_verification_result.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/resume_context.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/retry_policy_request.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/run_turn_request.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/run_turn_result.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_commit.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_engine_result.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_model_request.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_model_response.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/pipeline/turn_options.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/streaming/stream_options.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/template/format_config.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/template/parser_config.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/template/template.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/tools/binding.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/tools/mcp_approval_mode.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/tools/tool_context.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/tools/tool_dispatch_result.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/tools/tool.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/tracing/trace_file.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/tracing/trace_span.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/tracing/trace_time.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/TypraRuntime.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_image_block.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_image_source.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_messages_request.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_messages_response.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_text_block.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_tool_definition.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_tool_result_block.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_tool_use_block.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_usage.swift", + "marker": true + }, + { + "outputRoot": "../runtime/swift/prompty-model", + "path": "../runtime/swift/prompty-model/Sources/PromptyModel/wire/anthropic_wire_message.swift", + "marker": true + }, { "outputRoot": "../runtime/typescript/packages/core/src", "path": "../runtime/typescript/packages/core/src/eslint.config.js", diff --git a/schema/tspconfig.yaml b/schema/tspconfig.yaml index a4be8b639..f7e86eef0 100644 --- a/schema/tspconfig.yaml +++ b/schema/tspconfig.yaml @@ -32,5 +32,36 @@ options: output-dir: "../runtime/rust/prompty/src/model" test-dir: "../runtime/rust/prompty/tests/model" import-path: "prompty::model" + - type: Swift + output-dir: "../runtime/swift/prompty-model" + package-name: "PromptyModel" + # test-dir is intentionally omitted: the Typra Swift emitter emits + # tests that do not compile. Measured by generating and building + # them: 1184 errors at 0.4.3 and 180 at 0.4.8. At 0.4.9, after + # probe-patching 48 source errors so the test target could build, the + # same 180 test errors remained, in four classes: element-level enum + # cases applied to array/optional types (`[Tool]?` has no member + # `customTool`), struct-style member access on polymorphic enums + # (`Property.kind`, where `Property` is an enum) alongside references + # to properties that do not exist (`ApiKeyConnection.key`; the + # property is `apiKey`), a typed enum compared against a raw string + # (`AuthenticationMode` vs `String`), and mis-cased or unqualified + # type references (`mcpApprovalModeKind` and `apiType` for the emitted + # `McpApprovalModeKind` and `ApiType`). These are test-generator + # defects, disjoint from the source-generator ones fixed in 0.4.3 and + # 0.4.6. The library must compile before this is measurable: if it + # fails first, the test target is never built and the error count is + # misleadingly small. Reported upstream; restore test-dir once fixed. + # + # The counts above are raw `error:` lines. Later probes report + # primary unique errors instead -- deduplicated whole lines, minus + # Swift's caret-annotation repeats -- because raw counts swing with + # incremental-build caching (the same state measured 392 and 98). + # On that normalised basis: 49 across five files at 0.4.5, 45 at + # 0.4.9, and 45 at 0.4.10. Do not compare a normalised figure against + # the raw ones above; compare failing identities, not counts. At + # least four of the ModelTests failures are ours, not the emitter's + # -- see the stale `@sample` blocks at model/model/model.tsp L86/L93. + # Model round-trip coverage lives in runtime/swift/prompty meanwhile. - type: markdown output-dir: "../web/src/content/docs/reference"