Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/prompty-ts-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ jobs:
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm run format:check
- run: npm run build
- run: npm run test
- run: npm run lint
75 changes: 27 additions & 48 deletions .github/workflows/schema-repro-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ on:
- 'runtime/python/prompty/uv.lock'
- 'runtime/go/prompty/go.mod' # gofmt is toolchain-tied; go-version-file below
- '.editorconfig' # consumed by dotnet format
# Committed toolchain pins: bumping either changes the rustfmt / dotnet format
# version the emitter shells out to, so a change here alters generated output.
- 'rust-toolchain.toml' # rustfmt (cargo fmt) toolchain
- 'global.json' # .NET SDK dotnet format selects
# Hand-written C# is a real input to this check, unlike the other languages.
# The C# driver runs `dotnet format <csproj>` over the WHOLE project, so every
# file in these two projects must already be dotnet format-clean or regeneration
Expand All @@ -49,6 +53,16 @@ on:
workflow_call:
workflow_dispatch:

# Defence in depth. Path triggers only catch inputs pinned by a file in the repo;
# they cannot see a floating input that drifts with no commit at all — a runner
# image rotating its default SDK, a transitive formatter dependency resolving to a
# new version, a base image bump. A weekly run on the default branch surfaces that
# class of drift on a fixed cadence instead of on some unrelated contributor's next
# schema PR, where it would fail red and blame the wrong change. Largely subsumed by
# the committed toolchain pins, kept as a backstop for what those pins cannot bind.
schedule:
- cron: '17 9 * * 1' # Mondays 09:17 UTC

permissions:
contents: read

Expand Down Expand Up @@ -77,16 +91,16 @@ jobs:
# commit. Please do not "helpfully" restore floating versions here.
# These values are the ones that produced the verified-green first run.
#
# KNOWN LIMITATION, accepted deliberately. Pinning here fixes CI-side drift but
# introduces a second-order skew: a developer running `cargo fmt` on rustup
# stable, or `dotnet format` on whichever SDK they have, can produce output this
# job rejects. Workflow pins bind CI only. The durable instrument is a root
# KNOWN LIMITATION, historically accepted. Pinning here fixes CI-side drift
# but introduced a second-order skew: a developer running `cargo fmt` on
# rustup stable, or `dotnet format` on whichever SDK they have, could produce
# output this job rejected. The durable instrument is a root
# `rust-toolchain.toml` plus a committed `global.json`, which rustup, rustc,
# `dtolnay/rust-toolchain` and the .NET CLI all honour — making local and CI
# agree by construction instead of by coincidence. That changes the toolchain
# for every Rust and C# developer in this repo, so it belongs in its own PR
# with its own review, not as a rider on a CI change. Until then, if this gate
# disagrees with your local formatter, check these versions first.
# agree by construction instead of by coincidence. Both now exist at the repo
# root (added deliberately, changing the toolchain for every Rust and C#
# developer here), so `dotnet format` no longer needs the ephemeral global.json
# this job used to write, and the action pins below simply mirror them.
- uses: actions/setup-node@v7
with:
node-version: '22.23.2'
Expand Down Expand Up @@ -152,43 +166,17 @@ jobs:
working-directory: schema
run: npm ci

# setup-dotnet's `dotnet-version` installs an SDK, it does NOT select one.
# Measured on the previous run of this workflow: with `dotnet-version: 9.0.317`
# the image still resolved `dotnet --version` to 10.0.302, because it carries
# eleven SDKs (8.0.129 through 10.0.302) and absent a global.json the highest
# wins. The pin alone was doing nothing for `dotnet format`, leaving it the one
# formatter that could still float with no repo change at all.
#
# 9.0.317, matching the net9.0 target of every csproj under runtime/csharp/ and
# the `9.0.x` that prompty-csharp-check.yml, prompty-csharp.yml and
# docs-examples.yml already pin, so this gate does not disagree with the rest of
# the repo about which SDK is authoritative.
# The committed root global.json (added alongside this change) selects the SDK
# for `dotnet format`. setup-dotnet above installs 9.0.317; the committed
# global.json pins the 9.0 feature band (rollForward: latestFeature), so on a
# fresh runner the only 9.0.x SDK present — 9.0.317 — is the one selected. This
# replaces the ephemeral global.json this job used to write and delete.
#
# Honest caveat, measured: no SDK-dependent difference has been observed on this
# tree. 9.0.317, 10.0.111 and 10.0.302 all produced byte-identical output (the
# same git blob) for the one file where formatting was ever in question. The pin
# is therefore precautionary — it makes the SDK an explicit, reviewable input
# rather than a property of whatever image the runner happens to boot.
#
# This global.json is written here and deleted before the diff assertion — it is
# deliberately NOT committed, because a repo-root global.json would constrain
# every other .NET job and build in the repo, which is a much larger decision.
# rollForward: disable means a future image that drops 9.0.317 fails loudly with
# "requested SDK not found" — unambiguous and attributable — rather than silently
# formatting with a new SDK and reporting the result as generated-code drift.

# Pin the SDK that dotnet format will use
- name: Pin the SDK that dotnet format will use
shell: bash
run: |
cat > global.json <<'JSON'
{
"sdk": {
"version": "9.0.317",
"rollForward": "disable"
}
}
JSON

# Record every version that can change the output. When this gate goes red,
# formatter?" — this makes that answerable from the log alone instead of by
Expand Down Expand Up @@ -232,15 +220,6 @@ jobs:
exit 1
fi

# Must run before the assertion below: global.json is untracked, and
# `git status --porcelain` reports untracked files, so leaving it in place
# would fail the very check it exists to make trustworthy. `if: always()`
# so a failed generate still cleans up rather than confusing the failure log.
- name: Remove the temporary global.json
if: always()
shell: bash
run: rm -f global.json

- name: Assert the tree is unchanged
shell: bash
run: |
Expand Down
6 changes: 6 additions & 0 deletions global.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "9.0.317",
"rollForward": "latestFeature"
}
}
49 changes: 46 additions & 3 deletions runtime/rust/prompty-foundry/src/arm_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,7 @@ async fn fetch_all(token: &str, first_url: String) -> Result<Vec<Value>, String>
.await
.map_err(|e| format!("Failed to parse ARM response: {e}"))?;

if let Some(arr) = body.get("value").and_then(|v| v.as_array()) {
items.extend(arr.iter().cloned());
}
items.extend(page_objects(&body));

next = body
.get("nextLink")
Expand All @@ -153,6 +151,22 @@ async fn fetch_all(token: &str, first_url: String) -> Result<Vec<Value>, String>
Ok(items)
}

/// Extract the object entries of an ARM list page's `value` array.
///
/// ARM list responses carry results in a `value` array. Only JSON objects can be
/// valid resources, so non-object entries (`null`, strings, numbers) are dropped
/// here. Without this filter a stray non-object entry would reach the infallible
/// `parse_s1_project` mapping and become a phantom `FoundryProject` with empty
/// fields, which in turn makes `projects` non-empty and suppresses the classic-hub
/// fallback (#445). This mirrors the Java runtime's paging helper, keeping the two
/// behavioural references in agreement.
fn page_objects(body: &Value) -> Vec<Value> {
body.get("value")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter(|v| v.is_object()).cloned().collect())
.unwrap_or_default()
}

fn parse_subscription(v: &Value) -> Option<Subscription> {
let state = string_at(v, "state");
if state != "Enabled" {
Expand Down Expand Up @@ -425,4 +439,33 @@ mod tests {
let res = AiResource::load_from_value(&v, &prompty::model::context::LoadContext::default());
assert_eq!(res.service_url, None);
}

// Regression tests for #445: a non-object `value` entry must not become a
// phantom project (and must not suppress the classic-hub fallback).
#[test]
fn page_objects_skips_non_object_entries() {
let body = json!({
"value": [
null,
"a-string",
42,
true,
[1, 2, 3],
{ "name": "acct/proj-a" }
]
});
let objs = page_objects(&body);
assert_eq!(objs.len(), 1, "only the object entry should survive");
assert_eq!(objs[0]["name"], "acct/proj-a");
}

#[test]
fn page_objects_empty_without_value_array() {
assert!(page_objects(&json!({})).is_empty());
assert!(page_objects(&json!({ "value": "not-an-array" })).is_empty());
assert!(page_objects(&json!({ "value": [] })).is_empty());
// A page consisting solely of non-object entries yields nothing, so the
// caller's `projects.is_empty()` fallback guard stays reachable.
assert!(page_objects(&json!({ "value": [null, "x", 1] })).is_empty());
}
}
2 changes: 2 additions & 0 deletions runtime/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"build": "npm run build --workspaces",
"test": "npm run test --workspaces",
"lint": "npm run lint --workspaces",
"format": "prettier --write \"packages/*/src/**/*.{ts,tsx}\" \"!packages/*/src/model/**\"",
"format:check": "prettier --check \"packages/*/src/**/*.{ts,tsx}\" \"!packages/*/src/model/**\"",
"clean": "npm run clean --workspaces",
"release": "node scripts/release.mjs"
},
Expand Down
30 changes: 26 additions & 4 deletions runtime/typescript/packages/anthropic/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@

import type Anthropic from "@anthropic-ai/sdk";
import type { Agent } from "@prompty/core";
import { ApiKeyConnection, ReferenceConnection, PromptyStream, Message, text } from "@prompty/core";
import {
ApiKeyConnection,
ReferenceConnection,
PromptyStream,
Message,
text,
} from "@prompty/core";
import type { Executor } from "@prompty/core";
import { getConnection } from "@prompty/core";
import { traceSpan, sanitizeValue } from "@prompty/core";
Expand Down Expand Up @@ -39,7 +45,13 @@ export class AnthropicExecutor implements Executor {
});

const apiType = agent.model?.apiType ?? "chat";
const result = await this.executeApiCall(client, clientName, agent, messages, apiType);
const result = await this.executeApiCall(
client,
clientName,
agent,
messages,
apiType,
);
emit("result", result);
return result;
});
Expand Down Expand Up @@ -107,7 +119,11 @@ export class AnthropicExecutor implements Executor {
});
}
messages.push(
new Message({ role: "assistant", parts: textContent ? [text(textContent)] : [], metadata: { content: rawContent } }),
new Message({
role: "assistant",
parts: textContent ? [text(textContent)] : [],
metadata: { content: rawContent },
}),
);

// Single user message with batched tool_result blocks
Expand All @@ -116,7 +132,13 @@ export class AnthropicExecutor implements Executor {
tool_use_id: tc.id,
content: toolResults[i],
}));
messages.push(new Message({ role: "user", parts: [], metadata: { tool_results: toolResultBlocks } }));
messages.push(
new Message({
role: "user",
parts: [],
metadata: { tool_results: toolResultBlocks },
}),
);

return messages;
}
Expand Down
7 changes: 6 additions & 1 deletion runtime/typescript/packages/anthropic/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@

export { AnthropicExecutor } from "./executor.js";
export { AnthropicProcessor, processResponse } from "./processor.js";
export { buildChatArgs, messageToWire, toolsToWire, outputsToWire } from "./wire.js";
export {
buildChatArgs,
messageToWire,
toolsToWire,
outputsToWire,
} from "./wire.js";

// Auto-register on import
import { registerExecutor, registerProcessor } from "@prompty/core";
Expand Down
39 changes: 29 additions & 10 deletions runtime/typescript/packages/anthropic/src/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export function messageToWire(msg: Message): Record<string, unknown> {

// Legacy single tool result messages (backward compat)
if (msg.metadata.tool_use_id || msg.metadata.tool_call_id) {
const toolUseId = (msg.metadata.tool_use_id ?? msg.metadata.tool_call_id) as string;
const toolUseId = (msg.metadata.tool_use_id ??
msg.metadata.tool_call_id) as string;
wire.role = "user";
wire.content = [
{
Expand All @@ -46,7 +47,11 @@ export function messageToWire(msg: Message): Record<string, unknown> {
}

// Assistant messages with raw content blocks (tool_use) — preserve them
if (msg.role === "assistant" && msg.metadata.content && Array.isArray(msg.metadata.content)) {
if (
msg.role === "assistant" &&
msg.metadata.content &&
Array.isArray(msg.metadata.content)
) {
wire.content = msg.metadata.content;
return wire;
}
Expand Down Expand Up @@ -83,7 +88,8 @@ function partToWire(part: ContentPart): Record<string, unknown> {
// Extract MIME type between "data:" and ";" without regex backtracking
const mimeStart = header?.indexOf("data:") === 0 ? 5 : 0;
const mimeEnd = header?.indexOf(";", mimeStart) ?? -1;
const mediaType = mimeEnd > mimeStart ? header!.slice(mimeStart, mimeEnd) : "image/png";
const mediaType =
mimeEnd > mimeStart ? header!.slice(mimeStart, mimeEnd) : "image/png";
return {
type: "image",
source: {
Expand Down Expand Up @@ -200,9 +206,12 @@ function buildOptions(agent: Agent): Record<string, unknown> {
}

function modelOptionsToWire(
opts: Record<string, unknown> & { toWire?: (provider: string) => Record<string, unknown> },
opts: Record<string, unknown> & {
toWire?: (provider: string) => Record<string, unknown>;
},
): Record<string, unknown> {
const result = typeof opts.toWire === "function" ? opts.toWire("anthropic") : {};
const result =
typeof opts.toWire === "function" ? opts.toWire("anthropic") : {};

const mappings: Record<string, string> = {
maxOutputTokens: "max_tokens",
Expand All @@ -215,7 +224,8 @@ function modelOptionsToWire(
for (const [key, target] of Object.entries(mappings)) {
const value = opts[key];
if (value === undefined || value === null) continue;
if (key === "stopSequences" && Array.isArray(value) && value.length === 0) continue;
if (key === "stopSequences" && Array.isArray(value) && value.length === 0)
continue;
result[target] = value;
}

Expand All @@ -231,7 +241,11 @@ function schemaToWire(properties: unknown[]): Record<string, unknown> {
const props: Record<string, unknown> = {};
const required: string[] = [];

for (const p of properties as Array<{ name?: string; required?: boolean } & Parameters<typeof propertyToJsonSchema>[0]>) {
for (const p of properties as Array<
{ name?: string; required?: boolean } & Parameters<
typeof propertyToJsonSchema
>[0]
>) {
if (!p.name) continue;
props[p.name] = propertyToJsonSchema(p);
if (p.required) required.push(p.name);
Expand All @@ -258,7 +272,8 @@ function propertyToJsonSchema(prop: {
const schema: Record<string, unknown> = jsonType ? { type: jsonType } : {};

if (prop.description) schema.description = prop.description;
if (prop.enumValues && prop.enumValues.length > 0) schema.enum = prop.enumValues;
if (prop.enumValues && prop.enumValues.length > 0)
schema.enum = prop.enumValues;

if (prop.kind === "array") {
schema.items = prop.items
Expand All @@ -270,7 +285,9 @@ function propertyToJsonSchema(prop: {
if (prop.properties) {
const nested: Record<string, unknown> = {};
const req: string[] = [];
for (const p of prop.properties as Array<{ name?: string } & typeof prop>) {
for (const p of prop.properties as Array<
{ name?: string } & typeof prop
>) {
if (!p.name) continue;
nested[p.name] = propertyToJsonSchema(p);
if (p.required) req.push(p.name);
Expand Down Expand Up @@ -343,7 +360,9 @@ export function toolsToWire(agent: Agent): Record<string, unknown>[] {
let params = (t as { parameters?: unknown[] }).parameters;
if (params && Array.isArray(params)) {
if (boundNames.size > 0) {
params = params.filter((p) => !boundNames.has((p as Record<string, unknown>).name as string));
params = params.filter(
(p) => !boundNames.has((p as Record<string, unknown>).name as string),
);
}
tool.input_schema = schemaToWire(params);
} else {
Expand Down
Loading
Loading