Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
99ab256
Generate the Swift Prompty model via Typra
sethjuarez Aug 4, 2026
86eb126
Add the Swift Prompty core runtime
sethjuarez Aug 4, 2026
68f04e2
Add the OpenAI provider for the Swift runtime
sethjuarez Aug 4, 2026
e5e4c6b
Add the turn harness and durability replay to the Swift runtime
sethjuarez Aug 4, 2026
a64ed07
Fix Windows line-ending handling and add live OpenAI end-to-end tests
sethjuarez Aug 4, 2026
56f638d
Document the Swift runtime
sethjuarez Aug 4, 2026
3d60d23
Guard inherited fields on composite Property subtypes
sethjuarez Aug 4, 2026
05f7040
style(swift): conform hand-written runtime to swift-format defaults
sethjuarez Aug 4, 2026
97c3a7a
build(schema): add PROMPTY_SKIP_SWIFT_SHIM validation escape hatch
sethjuarez Aug 4, 2026
d13109a
ci(swift): add build, test and format workflow for the Swift runtime
sethjuarez Aug 4, 2026
2a724c0
fix(swift): stop silently discarding CRLF frontmatter
sethjuarez Aug 4, 2026
66b60ec
chore(schema): adopt @typra/emitter 0.4.3 and minimize the Swift shim
sethjuarez Aug 4, 2026
0a5a5e9
revert(schema): drop the 0.4.3 emitter pin, keep the shim hardening
sethjuarez Aug 4, 2026
15f7e62
docs(schema): correct the `name` classification in the Swift shim header
sethjuarez Aug 4, 2026
fa15d77
docs(schema): record the @typra/emitter 0.4.8 probe
sethjuarez Aug 4, 2026
66de5c5
docs(schema): record the @typra/emitter 0.4.9 probe
sethjuarez Aug 4, 2026
406fc88
docs(schema): record the @typra/emitter 0.4.5 probe
sethjuarez Aug 4, 2026
75a3807
docs(schema): map the per-patch residual shim at emitter 0.4.5
sethjuarez Aug 4, 2026
dde5c61
docs(schema): record the @typra/emitter 0.4.10 probe
sethjuarez Aug 4, 2026
dfb3aee
docs(schema): reconcile the test-dir error counts across probes
sethjuarez Aug 4, 2026
383058c
test(swift): pin the Connection base-field gap with measured evidence
sethjuarez Aug 4, 2026
b2de8f7
docs(schema): record 0.4.7 emitter probe in the rejection ledger
sethjuarez Aug 4, 2026
8420a5a
test(swift): pin Property kind dispatch and the missing @coerce short…
sethjuarez Aug 4, 2026
90c3c6e
test(swift): make round-trip helpers agnostic to named-collection shape
sethjuarez Aug 4, 2026
841fdcf
docs(schema): record the 0.4.11 emitter candidates and the wildcard c…
sethjuarez Aug 4, 2026
4ef5ac4
swift: inject tool bindings at execution time
sethjuarez Aug 4, 2026
e269b18
swift: gate unknown Connection load/save/reload
sethjuarez Aug 4, 2026
97e682e
swift: order imports in ConnectionRoundTripTests
sethjuarez Aug 4, 2026
0553062
swift: gate strict ContentPart discriminator acceptance
sethjuarez Aug 4, 2026
f4f3916
swift: pin tools_function_load bindings map
sethjuarez Aug 4, 2026
1462501
schema: mark withdrawn emitter archives as ineligible in the shim ledger
sethjuarez Aug 4, 2026
4438af4
test(swift): wire canonical Property scalar-coercion vector
sethjuarez Aug 4, 2026
0286658
fix(swift): store named-collection scalar shorthand in default
sethjuarez Aug 4, 2026
8137246
test(swift): scope whole-float identity as runtime-specific, not port…
sethjuarez Aug 4, 2026
8cc967a
test(swift): assert Connection/Tool independence by identity, not pos…
sethjuarez Aug 4, 2026
393d274
test(swift): guard named-collection save-form fallback against entry …
sethjuarez Aug 5, 2026
b842108
docs(swift): separate emitter defect from schema gap in Connection shim
sethjuarez Aug 5, 2026
86f5ada
test(swift): pin how each wildcard strategy handles unknown fields
sethjuarez Aug 5, 2026
c39c9cf
fix(swift): stop coercing the strict-mode nonce
sethjuarez Aug 5, 2026
43db9af
test(swift): detect nested object-form save gaps in named collections
sethjuarez Aug 5, 2026
0b0708b
test(swift): pair named-collection children by key, not position
sethjuarez Aug 5, 2026
b1b3a3c
test(swift): gate canonical optional-collection presence rule
sethjuarez Aug 5, 2026
91382ca
test(swift): refuse name-addressing of ambiguous named collections
sethjuarez Aug 5, 2026
8252982
style(swift): satisfy swift-format lint in the new gate files
sethjuarez Aug 5, 2026
1daa76c
test(swift): assert first-save form and payload for disqualified coll…
sethjuarez Aug 5, 2026
41cfd7f
test(swift): gate absence of inputs and outputs, not just tools
sethjuarez Aug 5, 2026
039ad07
test(swift): pair binding expectations by the ordering rule, not name…
sethjuarez Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/prompty-swift-check.yml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 12 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
222 changes: 222 additions & 0 deletions runtime/swift/README.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions runtime/swift/prompty-model/Package.swift
Original file line number Diff line number Diff line change
@@ -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")
]
)
Loading
Loading