Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
01f3fbd
Add canonical engine port contracts
sethjuarez Aug 4, 2026
1bb6f7b
Propagate executor cancellation in C#
sethjuarez Aug 4, 2026
bff0740
Keep engine port errors runtime-native
sethjuarez Aug 4, 2026
8f80e79
Harden engine port acceptance probes
sethjuarez Aug 4, 2026
aa15771
Define unknown connection roundtrip contract
sethjuarez Aug 4, 2026
244f186
Add Rust connection roundtrip gate
sethjuarez Aug 4, 2026
5e418b8
Align message text joining across runtimes
sethjuarez Aug 4, 2026
b820d78
Define strict content part discriminators
sethjuarez Aug 4, 2026
31ff17d
Define canonical named collection contracts
sethjuarez Aug 4, 2026
d02c0b0
Define Record unknown nullability contract
sethjuarez Aug 4, 2026
0277feb
Harden engine port output verification
sethjuarez Aug 4, 2026
ec3b1be
test: enforce unknown connection preservation
sethjuarez Aug 4, 2026
0c64ef3
test: enforce closed content part discriminators
sethjuarez Aug 4, 2026
b633e77
test: validate function tool load bindings
sethjuarez Aug 4, 2026
70c9843
test(model): pin scalar property coercion
sethjuarez Aug 4, 2026
400ad4e
test(model): pin named scalar properties
sethjuarez Aug 4, 2026
ec87514
test(csharp): enforce null-valued records
sethjuarez Aug 4, 2026
c6bdfb2
test(csharp): pin nullable record signatures
sethjuarez Aug 4, 2026
772e9e9
Fix FunctionTool parameter example
sethjuarez Aug 4, 2026
7837c4f
test(model): gate unnamed composite fallback
sethjuarez Aug 4, 2026
67129e4
fix(schema): let Connection own unknown kinds via a bare string discr…
Aug 5, 2026
92e2380
wip(schema): regenerate all runtimes on a LOCALLY PATCHED emitter
Aug 5, 2026
00bb36a
wip(rust): repair handwritten seams broken by the 0.4.15 Option<Vec<T…
Aug 5, 2026
4677da1
docs: add operating rules to prevent the cross-runtime thrash from re…
Aug 5, 2026
d85ce0e
chore(schema): bump @typra/emitter to 0.4.20 and regenerate
sethjuarez Aug 5, 2026
095d37b
fix(harness): record tool_result when permission is denied
sethjuarez Aug 5, 2026
fd0ec40
fix(schema): make Prompty.model optional
sethjuarez Aug 5, 2026
7a1a128
chore(model): regenerate for optional Prompty.model
sethjuarez Aug 5, 2026
60473f0
fix(rust): adapt call sites to optional Prompty.model
sethjuarez Aug 5, 2026
bb838e1
test(ts): declare items on the array output fixture
sethjuarez Aug 5, 2026
1b18b53
fix(schema): make McpTool.approvalMode optional
sethjuarez Aug 5, 2026
03e361b
chore(model): regenerate for optional McpTool.approvalMode
sethjuarez Aug 5, 2026
de3e180
test(rust): supply required fields the schema declares
sethjuarez Aug 5, 2026
336d6c2
fix(rust): reject a bare-string template per the load vector
sethjuarez Aug 5, 2026
8377b01
fix(python): record denied tool_result and match load vectors
sethjuarez Aug 5, 2026
9cc0d77
fix(csharp): record denied tool_result and use flat parameters form
sethjuarez Aug 5, 2026
361e640
chore(model): bump @typra/emitter to 0.4.21 and regenerate
sethjuarez Aug 5, 2026
fecc12b
test(ts): remove orphaned generated test for a type no longer emitted
sethjuarez Aug 5, 2026
827c1b5
fix(python): apply known modalities per the enrichment vectors
sethjuarez Aug 5, 2026
11c450b
fix(vectors): supply tool parameters in a declared named-collection form
sethjuarez Aug 5, 2026
113a541
chore(model): bump @typra/emitter to 0.4.22 and regenerate
sethjuarez Aug 5, 2026
ba4866e
test(go): execute named-collection vectors against the Go emitted models
sethjuarez Aug 5, 2026
36c9619
test(typescript): execute named-collection vectors against the emitte…
sethjuarez Aug 5, 2026
1a0c3e2
test(csharp): execute named-collection vectors against the emitted mo…
sethjuarez Aug 5, 2026
e44ef13
chore(model): bump @typra/emitter to 0.4.23 and regenerate
sethjuarez Aug 5, 2026
e2152ef
fix(schema): declare the Property entry shorthand target as default
sethjuarez Aug 5, 2026
01fe620
test(python): execute named-collection vectors against the emitted mo…
sethjuarez Aug 5, 2026
c24239b
fix(rust): repair provider crates for optional Model and Property shapes
sethjuarez Aug 5, 2026
1bb0072
test(rust): supply schema-valid ModelInvocationRequest fixtures
sethjuarez Aug 5, 2026
19f45a5
test(typescript): correct stale ModelInfo modality assertions
sethjuarez Aug 6, 2026
9287680
fix(typescript): carry the raw payload and flat Foundry shape through…
sethjuarez Aug 6, 2026
b4acd24
test(typescript): execute the shared wire vectors against both providers
sethjuarez Aug 6, 2026
4494d96
chore(model): bump @typra/emitter to 0.4.25 and regenerate
sethjuarez Aug 6, 2026
4d26eb2
fix(schema): declare ArrayProperty items optional
sethjuarez Aug 6, 2026
2a0d468
fix(providers): emit bare array and object schemas when unspecified
sethjuarez Aug 6, 2026
00a040f
chore(model): bump @typra/emitter to 0.4.26
sethjuarez Aug 6, 2026
8cd08fa
fix(tests): supply tool parameters in the declared named-collection form
sethjuarez Aug 6, 2026
50f59b0
test(integration): make image generation opt-in via OPENAI_IMAGE_MODEL
sethjuarez Aug 6, 2026
38c7a5a
chore(model): bump @typra/emitter to 0.4.27
sethjuarez Aug 6, 2026
233ef47
test(wire): legislate per-provider option omission for Anthropic
sethjuarez Aug 6, 2026
f07df49
test(wire): enforce option omission in Python and C# wire runners
sethjuarez Aug 6, 2026
2798d7b
test(wire): legislate option omission on the Responses surface
sethjuarez Aug 6, 2026
b13459f
build(schema): make `npm run generate` idempotent for Rust
sethjuarez Aug 6, 2026
9ce13c1
ci(schema): gate that generated code matches the pinned emitter
sethjuarez Aug 6, 2026
6c78733
chore(model): bump @typra/emitter to 0.4.28 and regenerate
sethjuarez Aug 6, 2026
bf0bb00
chore(model): bump @typra/emitter to 0.4.29 and regenerate
sethjuarez Aug 6, 2026
9d7382d
fix(schema): emit a relative cancellation-token import for Python
sethjuarez Aug 6, 2026
7fa1b23
style(python): apply ruff format to README and the OpenAI executor
sethjuarez Aug 6, 2026
13ea1ec
ci: install every formatter the reproducibility gate depends on
sethjuarez Aug 6, 2026
752d80c
chore(model): bump @typra/emitter to 0.4.30 and regenerate
sethjuarez Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
148 changes: 137 additions & 11 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,127 @@
# Operating Rules — READ THIS FIRST

These rules are not style preferences. Each one is written down because ignoring it
already cost this project a multi-day outage across seven runtimes. The rest of this
file describes *what* to build; this section governs *how* to work. When the two
conflict, this section wins.

## 1. Vectors are the only authority

The files under `spec/vectors/**` define correctness. Nothing else does.

- **Before asserting any constraint, open the vector and quote it.** If you cannot cite
a specific vector file and the specific assertion in it, you do not have a
requirement — you have an opinion, and you must label it as one.
- **Do not inherit constraints from another session, a PR description, or a chat
summary.** Re-derive them from the vector yourself. A false constraint propagates
silently and is nearly impossible to kill once several sessions repeat it.
- **A vector that only checks `load → save → reload` equality does not require anything
about which type the payload loads as.** Read what is asserted, not what you assume is
implied.

> This exact failure: a "must deserialize as the base type" constraint circulated for a
> full day and drove an emitter-release escalation. It appears in no vector. The real
> requirement was byte-exact payload roundtrip, satisfied by a two-line schema edit.

## 2. Establish a green baseline before touching a generated-code dependency

Bumping `@typra/emitter` (or any codegen dependency) regenerates thousands of files
across every runtime at once. Without a baseline you cannot tell a regression you just
introduced from a failure that was already there.

Required sequence, in order:

1. `git stash` everything. Run the full suite on the untouched pin. **Record the exact
pass/fail counts.** If it is not green, stop and report that first.
2. Bump the dependency alone, in its own commit, and regenerate.
3. Re-run and diff against the recorded baseline.
4. Attribute every new failure before writing any fix.

Never bump a codegen dependency in the same commit as a schema change or a handwritten
fix. Keep schema edits, regeneration, and handwritten-seam repairs in **separate
commits**, always in that order, so each can be reviewed and reverted independently.

## 3. Try the cheap experiment before escalating

Escalation — asking for a publish, a release, an approval, or another team's change —
is the **last** resort, not the first.

Before escalating anything, you must have run the direct experiment. Concretely: if a
question is "which schema shape satisfies this vector?", the answer is 10 minutes of
editing the `.tsp` and running the test, times the two or three candidate shapes. Do
that, report a measured comparison, and the argument ends.

**Hard rule: never let a thread run on whether a version exists, is publishable, or is
authorized.** That is an availability argument, and it cannot resolve a correctness
question. If you notice a thread arguing about versions, stop and go measure instead.

> This exact failure: ~24 hours were spent arguing about which `@typra/emitter` version
> could be published. No publish was ever needed. Three candidate schema shapes were
> A/B'd afterward in under an hour and settled it outright.

## 4. Report measurements, never impressions

Every status claim must carry a number and a command that produces it.

- Good: `cargo test -p prompty` → `293 passed / 3 failed`; failures at
`tool.rs:115`, uninvestigated.
- Unacceptable: "mostly working", "should be fine now", "parity achieved".

**State explicitly what you did not verify.** An unqualified success report that hides
six untested runtimes is worse than no report, because it gets built upon. If you tested
one runtime, say that one runtime was tested and name the six that were not.

## 5. Orchestration has a hard budget

One session per branch per PR. Beyond that:

- **Give every child session a falsifiable definition of done** — a command and an
expected result, not a narrative goal.
- **Two exchanges on the same blocker is the cap.** If a blocker survives two rounds of
messaging, stop delegating and reproduce it yourself in one worktree. Coordination
cost grows with the number of sessions; debugging cost does not.
- **Never let sessions negotiate with each other about a shared dependency.** Route it
to a single owner — the coordinator — who decides by measurement.
- **`archive_session` only works on sessions you created.** "Idle" is not "stopped": an
idle session will resume on its next message. If you need work halted and cannot
archive a session, say so plainly and name the sessions the user must stop.

## 6. When told to stop, commit and preserve

On "stop", the work is not finished — it is *preserved*. Do this before reporting:

1. Commit every dirty worktree to a `wip/<topic>` branch. Never leave uncommitted work.
2. Split into reviewable commits so the user can cherry-pick the good parts out of the
bad ones.
3. In each commit message, state what is verified, what is not, and what is actively
known to be broken.
4. **Flag any state that is not reproducible from the manifest** — a `--no-save`
install, a locally built package, a hand-edited `node_modules`. Someone will run
`npm ci` and silently get different bytes.
5. Delete stale artifacts from abandoned experiments rather than committing them.

## 7. Repo-specific facts that waste time when rediscovered

- **Push with the `ssh` remote.** `origin` (`https://github.com/microsoft/prompty`)
fails with a SAML SSO 403.
- **`origin/*` tracking refs go stale**, so "N unpushed commits" and `@{u}` comparisons
routinely lie. Verify against the `ssh` remote or `gh pr view` before concluding work
was lost.
- **npm versions are immutable.** A version already published can never be republished
with different bytes. Any fix requires a strictly higher version number.
- **`Connection` is an open discriminator**: bare `kind: string` on an `@abstract` base,
with `@discriminator("kind")`. **Do not add a `kind: "*"` wildcard subtype** — a
wildcard serializes only declared fields and structurally drops the arbitrary
top-level keys the vectors require. This is the one place the `Tool` / `CustomTool`
pattern must *not* be copied.
- **A non-abstract polymorphic base must absorb discriminator values no subtype claims.**
`Property` is concrete and its union permits `string`, `integer`, `float`, `boolean`,
`thread`, `audio` — none of which have subtypes. They must load as the base, not
panic. Emitter ≥ 0.4.15 regressed this; symptoms show up identically in every
language (Go returned a zero `Property`; Rust panics).

---

# Prompty v2 — Complete Rebuild Plan

## Overview
Expand Down Expand Up @@ -414,7 +538,7 @@ Integration tests live in `runtime/python/prompty/tests/integration/` (see Phase
| -------------------- | ------------------------------------------------------ |
| `test_chat.py` | Chat completions against real OpenAI / Azure endpoints |
| `test_embedding.py` | Embedding API against real endpoints |
| `test_image.py` | DALL-E 2 image generation (OpenAI only) |
| `test_image.py` | Image generation (OpenAI only; opt-in via `OPENAI_IMAGE_MODEL`) |
| `test_agent.py` | Agent loop with tool calling against real endpoints |
| `test_streaming.py` | Streaming chat completions |
| `test_structured.py` | Structured output via outputs / response_format |
Expand Down Expand Up @@ -690,10 +814,9 @@ tools:
kind: function
description: Get the current weather
parameters:
properties:
location:
kind: string
description: City and state
location:
kind: string
description: City and state
---
system:
You are a helpful assistant with access to tools.
Expand Down Expand Up @@ -1210,7 +1333,7 @@ Configured via `.env` in the package root (`runtime/python/prompty/.env`), alrea
| -------------------- | ----- | --------------------------------------------------------- |
| `test_chat.py` | 5 | Basic + async chat, temperature control, both providers |
| `test_embedding.py` | 5 | Single + batch + async embeddings, both providers |
| `test_image.py` | 1 | DALL-E 2 image generation (OpenAI only, 256x256 for cost) |
| `test_image.py` | 1 | Image generation (OpenAI only; opt-in via `OPENAI_IMAGE_MODEL`) |
| `test_agent.py` | 3 | Tool-calling agent loop, sync + async, both providers |
| `test_streaming.py` | 3 | Streaming chat with PromptyStream/AsyncPromptyStream |
| `test_structured.py` | 4 | Structured output via outputs → response_format |
Expand Down Expand Up @@ -1254,11 +1377,14 @@ agent = make_openai_agent(
"name": "get_weather",
"kind": "function",
"description": "Get the current weather",
"parameters": {
"properties": [
{"name": "city", "kind": "string", "required": True}
]
},
# FunctionTool.parameters is a `Properties` named collection
# (schema/model/tools/tool.tsp). Use the declared list form. Wrapping it
# in {"properties": [...]} parses as name-keyed object form with an array
# under a key, which the loader rejects:
# "tools.parameters.properties: invalid named collection entry category array"
"parameters": [
{"name": "city", "kind": "string", "required": True}
],
}],
metadata={"tool_functions": {"get_weather": get_weather}},
)
Expand Down
102 changes: 102 additions & 0 deletions .github/workflows/schema-repro-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
name: schema generated-code reproducibility

# Asserts that the committed generated code is exactly what the pinned
# @typra/emitter produces. Nothing enforced this before, which is how two
# commits on this repo (00a040fb -> 0.4.26, 38c7a5a0 -> 0.4.27) bumped the
# emitter pin without regenerating and ran zero CI: every runtime workflow
# is paths-filtered to runtime/<lang>/**, so a schema-only commit triggered
# nothing. Both happened to be no-ops, but that was luck, not a gate.
on:
pull_request:
paths:
- 'schema/**'
- '.github/workflows/schema-repro-check.yml'

workflow_call:
workflow_dispatch:

jobs:
regenerate-is-idempotent:
name: regeneration produces no diff
runs-on: ubuntu-latest
permissions:
contents: read
env:
# runtime/python/prompty/uv.lock is tracked, and the Python emitter shells
# out to `uv run ruff`, which would otherwise re-lock and dirty the tree —
# reporting as generated-code staleness. Frozen turns that into an explicit
# lockfile error instead of a misattributed diff.
UV_FROZEN: '1'
steps:
- uses: actions/checkout@v5

- uses: actions/setup-node@v4
with:
node-version: '20'

# Generation is only reproducible when every formatter the emitter shells
# out to is present. Each one degrades to a warning when missing, so an
# absent formatter reports as a large cosmetic diff and is easily
# misread as stale generated code. All three are installed below, and
# the regenerate step fails on the warnings rather than trusting them.
#
# rustfmt normalize-typra-output.mjs runs `cargo fmt -p prompty`
# (the Rust emitter does not format its own output)
# prettier typescript/driver.js walks up from the TypeScript project
# root for node_modules/prettier/bin/prettier.cjs, then runs
# `npx eslint --fix` from the same workspace
# ruff python/driver.js runs `uv run ruff check --fix` and
# `uv run ruff format` from runtime/python/prompty
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt

- name: Install TypeScript workspace (provides prettier and eslint)
working-directory: runtime/typescript
run: npm ci

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Install Python dev tooling (provides ruff)
working-directory: runtime/python/prompty
run: |
uv venv
uv pip install -e ".[dev]"

- name: Install schema dependencies
working-directory: schema
run: npm ci

- name: Regenerate from the pinned emitter
working-directory: schema
run: |
set -o pipefail
npm run generate 2>&1 | tee "$RUNNER_TEMP/generate.log"

# A missing formatter produces a warning and unformatted output. That
# is indistinguishable from stale generated code in the diff below, so
# fail here with the real cause instead of reporting false staleness.
if grep -Eq 'prettier not found|prettier formatting failed|ruff check failed|ruff format failed|did not run; generated Rust is unformatted' "$RUNNER_TEMP/generate.log"; then
echo "::error::A formatter was unavailable during generation, so the output is not comparable to the committed tree."
echo "This is an environment problem in this job, not stale generated code."
grep -E 'prettier|ruff|cargo fmt' "$RUNNER_TEMP/generate.log" || true
exit 1
fi

- name: Assert the tree is unchanged
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "::error::Regenerating from the pinned @typra/emitter changed the tree."
echo "The committed generated code does not match the pinned emitter version."
echo "Fix: run 'npm run generate' in schema/ and commit the result."
echo ""
echo "--- changed files ---"
git status --porcelain
echo ""
echo "--- diff ---"
git --no-pager diff --stat
exit 1
fi
echo "Generated code matches the pinned emitter exactly."
5 changes: 4 additions & 1 deletion runtime/csharp/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ OPENAI_API_KEY=
OPENAI_BASE_URL=
OPENAI_MODEL=gpt-4o-mini
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
OPENAI_IMAGE_MODEL=dall-e-2
# Image generation is opt-in and billable; leave blank to skip those tests.
# Model availability is account-specific: dall-e-2 / dall-e-3 are retired on
# current accounts (400 "does not exist"); newer accounts expose gpt-image-1.
OPENAI_IMAGE_MODEL=

# Direct OpenAI
DIRECT_OPENAI_API_KEY=
Expand Down
12 changes: 11 additions & 1 deletion runtime/csharp/Prompty.Anthropic.Tests/AnthropicExecutorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ namespace Prompty.Anthropic.Tests;
/// </summary>
public class AnthropicExecutorTests
{
[Fact]
public async Task ExecuteAsync_Cancelled_ThrowsBeforeConnectionValidation()
{
var executor = new Anthropic.AnthropicExecutor();
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();

await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => executor.ExecuteAsync(new Core.Prompty(), [], cancellation.Token));
}

[Fact]
public async Task ExecuteAsync_MissingApiKey_ThrowsInvalidOperationException()
{
Expand Down Expand Up @@ -156,4 +167,3 @@ public void FormatToolMessages_NoTextContent_OmitsTextBlock()
Assert.Equal("tool_use", content[0]["type"]);
}
}

26 changes: 18 additions & 8 deletions runtime/csharp/Prompty.Anthropic/AnthropicExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,30 +19,40 @@ public class AnthropicExecutor : IExecutor
private const string ApiVersion = "2023-06-01";
private const int DefaultMaxTokens = 4096;

public async Task<object> ExecuteAsync(Core.Prompty agent, List<Message> messages)
public async Task<object> ExecuteAsync(
Core.Prompty agent,
List<Message> messages,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var streaming = agent.Metadata?.TryGetValue("stream", out var streamVal) == true && streamVal is true;

if (streaming)
return ExecuteStreamAsync(agent, messages);
return ExecuteStreamAsync(agent, messages, cancellationToken);

return await ExecuteNonStreamAsync(agent, messages);
return await ExecuteNonStreamAsync(agent, messages, cancellationToken);
}

private async Task<object> ExecuteNonStreamAsync(Core.Prompty agent, List<Message> messages)
private async Task<object> ExecuteNonStreamAsync(
Core.Prompty agent,
List<Message> messages,
CancellationToken cancellationToken)
{
var body = BuildRequestBody(agent, messages, stream: false);
var (endpoint, apiKey) = GetConnectionInfo(agent);

var request = CreateRequest(endpoint, apiKey, body);
var response = await _httpClient.SendAsync(request);
var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();

var json = await response.Content.ReadFromJsonAsync<JsonElement>();
var json = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: cancellationToken);
return json;
}

private PromptyStream ExecuteStreamAsync(Core.Prompty agent, List<Message> messages)
private PromptyStream ExecuteStreamAsync(
Core.Prompty agent,
List<Message> messages,
CancellationToken cancellationToken)
{
var body = BuildRequestBody(agent, messages, stream: true);
var (endpoint, apiKey) = GetConnectionInfo(agent);
Expand All @@ -69,7 +79,7 @@ async IAsyncEnumerable<object> StreamEvents([System.Runtime.CompilerServices.Enu
}
}

return new PromptyStream(StreamEvents());
return new PromptyStream(StreamEvents(cancellationToken));
}

internal Dictionary<string, object?> BuildRequestBody(Core.Prompty agent, List<Message> messages, bool stream)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ public Task<List<Message>> ParseAsync(Prompty agent, string rendered, Dictionary

public void EnqueueResponse(object response) => _responses.Enqueue(response);

public Task<object> ExecuteAsync(Prompty agent, List<Message> messages)
public Task<object> ExecuteAsync(
Prompty agent,
List<Message> messages,
CancellationToken cancellationToken = default)
{
// Snapshot the messages at call time
Calls.Add(new List<Message>(messages));
Expand Down
Loading
Loading