From 0447616892adb9854fb97c1e653808a4c885c4ec Mon Sep 17 00:00:00 2001 From: Seth Juarez Date: Mon, 17 Aug 2026 23:24:53 -0700 Subject: [PATCH 1/4] fix(rust-foundry): skip non-object ARM page entries in discovery (#445) fetch_all accumulated every value-array entry, so a stray non-object entry (null/string/number) in the S1 projects response became a phantom FoundryProject with empty fields. That made projects non-empty and suppressed the classic-hub (S2) fallback, hiding the real projects. Extract a page_objects helper that keeps only JSON objects and call it from fetch_all, mirroring the Java runtime's paging helper so the two behavioural references agree. Add regression tests analogous to Java's FoundryArmTest.Paging.nonObjectEntriesAreSkipped. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../rust/prompty-foundry/src/arm_discovery.rs | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/runtime/rust/prompty-foundry/src/arm_discovery.rs b/runtime/rust/prompty-foundry/src/arm_discovery.rs index fc3834ee5..47e7f953a 100644 --- a/runtime/rust/prompty-foundry/src/arm_discovery.rs +++ b/runtime/rust/prompty-foundry/src/arm_discovery.rs @@ -139,9 +139,7 @@ async fn fetch_all(token: &str, first_url: String) -> Result, 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") @@ -153,6 +151,22 @@ async fn fetch_all(token: &str, first_url: String) -> Result, 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 { + 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 { let state = string_at(v, "state"); if state != "Enabled" { @@ -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()); + } } From 0992dcc2129f294f240e5a45f3c0e89a75d21112 Mon Sep 17 00:00:00 2001 From: Seth Juarez Date: Mon, 17 Aug 2026 23:29:16 -0700 Subject: [PATCH 2/4] build: pin repo-wide Rust and .NET toolchains (#474) Add a root rust-toolchain.toml (channel 1.97.1 + rustfmt/clippy) and a root global.json (9.0.317, rollForward latestFeature) so local and CI toolchains agree by construction. rustup, cargo, dtolnay/rust-toolchain and the .NET CLI all honour these files, which is what makes 'npm run generate' reproducible off-CI and stops false generated-code drift in schema-repro-check. global.json uses rollForward: latestFeature (not disable) so it stays compatible with every 9.0.x setup-dotnet pin already used across the C# workflows, while blocking accidental roll to 10.x. dotnet format output was measured SDK-invariant, so exactness is unnecessary and would needlessly break contributors. Simplify schema-repro-check.yml: the committed global.json replaces the ephemeral one the job wrote and deleted, so both steps are removed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/schema-repro-check.yml | 61 +++++------------------- global.json | 6 +++ rust-toolchain.toml | 10 ++++ 3 files changed, 29 insertions(+), 48 deletions(-) create mode 100644 global.json create mode 100644 rust-toolchain.toml diff --git a/.github/workflows/schema-repro-check.yml b/.github/workflows/schema-repro-check.yml index 3591de639..f0d1733be 100644 --- a/.github/workflows/schema-repro-check.yml +++ b/.github/workflows/schema-repro-check.yml @@ -77,16 +77,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' @@ -152,43 +152,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 @@ -232,15 +206,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: | diff --git a/global.json b/global.json new file mode 100644 index 000000000..114bdb359 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "9.0.317", + "rollForward": "latestFeature" + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 000000000..5c690507e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,10 @@ +# Pins the Rust toolchain for every crate in this repo so local builds and CI +# agree by construction. rustup, cargo and `dtolnay/rust-toolchain` all honour +# this file, which matters most for `cargo fmt`: the schema generator runs +# `cargo fmt --all` over the emitted Rust, and a floating rustfmt reformats that +# output differently, showing up as false generated-code drift in +# .github/workflows/schema-repro-check.yml. The channel matches the toolchain +# that workflow pins (dtolnay/rust-toolchain @ 1.97.1). +[toolchain] +channel = "1.97.1" +components = ["rustfmt", "clippy"] From 6f943fb8a0ea1287ff39986685e9dec08e0cafb2 Mon Sep 17 00:00:00 2001 From: Seth Juarez Date: Mon, 17 Aug 2026 23:30:01 -0700 Subject: [PATCH 3/4] ci: run schema reproducibility check weekly (#477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a weekly schedule trigger to schema-repro-check so drift from inputs no path filter can see — a runner image rotating its default SDK, a transitive formatter dep, a base image bump — is surfaced on a fixed cadence instead of on an unrelated contributor's next schema PR. Largely subsumed by the committed toolchain pins (#474); kept as a backstop for what those pins cannot bind. Also add rust-toolchain.toml and global.json to the pull_request trigger paths, since bumping either changes the rustfmt / dotnet format version the emitter uses. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/schema-repro-check.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/schema-repro-check.yml b/.github/workflows/schema-repro-check.yml index f0d1733be..49bcf836b 100644 --- a/.github/workflows/schema-repro-check.yml +++ b/.github/workflows/schema-repro-check.yml @@ -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 ` over the WHOLE project, so every # file in these two projects must already be dotnet format-clean or regeneration @@ -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 From c11a9a7a01f156d67dc260219e7ff96d484a38c1 Mon Sep 17 00:00:00 2001 From: Seth Juarez Date: Mon, 17 Aug 2026 23:33:56 -0700 Subject: [PATCH 4/4] ci(typescript): add prettier format check for hand-written source (#475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/*/lint was only 'tsc --noEmit', so hand-written TypeScript had no formatting verification. That let the prettier 3.8.3 -> 3.9.6 bump in #465 sit unnoticed on main until an unrelated schema-repro run found it four months later. Add root 'format' and 'format:check' scripts (prettier over packages/*/src, excluding the Typra-emitted packages/*/src/model so this hand-written check never polices generated code — that stays schema-repro-check's job), reformat the 37 hand-written source files to prettier 3.9.6 (the repo's own lockfile version), and run 'npm run format:check' in the TypeScript CI. Generated model/ was already 3.9.6-clean and is untouched; build, tests and tsc all pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/prompty-ts-check.yml | 1 + runtime/typescript/package.json | 2 + .../packages/anthropic/src/executor.ts | 30 +- .../packages/anthropic/src/index.ts | 7 +- .../typescript/packages/anthropic/src/wire.ts | 39 +- .../packages/core/src/core/agent-events.ts | 8 +- .../packages/core/src/core/connections.ts | 2 +- .../packages/core/src/core/context.ts | 26 +- .../packages/core/src/core/guardrails.ts | 5 +- .../packages/core/src/core/index.ts | 21 +- .../packages/core/src/core/interfaces.ts | 12 +- .../packages/core/src/core/loader.ts | 43 +- .../packages/core/src/core/pipeline.ts | 519 +++++++++++++----- .../packages/core/src/core/registry.ts | 4 +- .../packages/core/src/core/steering.ts | 5 +- .../packages/core/src/core/tool-decorator.ts | 21 +- .../packages/core/src/core/tool-dispatch.ts | 40 +- .../packages/core/src/core/types.ts | 25 +- .../packages/core/src/harness/adapters.ts | 23 +- .../core/src/harness/replay-verifier.ts | 15 +- .../packages/core/src/harness/turn-runner.ts | 84 ++- runtime/typescript/packages/core/src/index.ts | 3 +- .../packages/core/src/parsers/prompty.ts | 36 +- .../packages/core/src/renderers/index.ts | 6 +- .../packages/core/src/renderers/nunjucks.ts | 43 +- .../packages/core/src/tracing/console.ts | 8 +- .../packages/core/src/tracing/index.ts | 9 +- .../packages/core/src/tracing/otel.ts | 16 +- .../core/src/tracing/promptyTracer.ts | 23 +- .../packages/core/src/tracing/tracer.ts | 20 +- .../packages/foundry/src/azure-executor.ts | 39 +- .../packages/foundry/src/azure-models.ts | 95 +++- .../packages/foundry/src/executor.ts | 65 ++- .../packages/openai/src/executor.ts | 92 +++- .../typescript/packages/openai/src/index.ts | 8 +- .../typescript/packages/openai/src/models.ts | 58 +- .../packages/openai/src/processor.ts | 6 +- .../typescript/packages/openai/src/wire.ts | 148 +++-- 38 files changed, 1222 insertions(+), 385 deletions(-) diff --git a/.github/workflows/prompty-ts-check.yml b/.github/workflows/prompty-ts-check.yml index 597ce92a7..4a221bec0 100644 --- a/.github/workflows/prompty-ts-check.yml +++ b/.github/workflows/prompty-ts-check.yml @@ -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 diff --git a/runtime/typescript/package.json b/runtime/typescript/package.json index 3848bc880..f44fe7efb 100644 --- a/runtime/typescript/package.json +++ b/runtime/typescript/package.json @@ -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" }, diff --git a/runtime/typescript/packages/anthropic/src/executor.ts b/runtime/typescript/packages/anthropic/src/executor.ts index fcfa8b248..9f1b3fcf1 100644 --- a/runtime/typescript/packages/anthropic/src/executor.ts +++ b/runtime/typescript/packages/anthropic/src/executor.ts @@ -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"; @@ -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; }); @@ -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 @@ -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; } diff --git a/runtime/typescript/packages/anthropic/src/index.ts b/runtime/typescript/packages/anthropic/src/index.ts index 6602e6868..529a8dca8 100644 --- a/runtime/typescript/packages/anthropic/src/index.ts +++ b/runtime/typescript/packages/anthropic/src/index.ts @@ -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"; diff --git a/runtime/typescript/packages/anthropic/src/wire.ts b/runtime/typescript/packages/anthropic/src/wire.ts index f686b2de1..53504840f 100644 --- a/runtime/typescript/packages/anthropic/src/wire.ts +++ b/runtime/typescript/packages/anthropic/src/wire.ts @@ -33,7 +33,8 @@ export function messageToWire(msg: Message): Record { // 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 = [ { @@ -46,7 +47,11 @@ export function messageToWire(msg: Message): Record { } // 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; } @@ -83,7 +88,8 @@ function partToWire(part: ContentPart): Record { // 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: { @@ -200,9 +206,12 @@ function buildOptions(agent: Agent): Record { } function modelOptionsToWire( - opts: Record & { toWire?: (provider: string) => Record }, + opts: Record & { + toWire?: (provider: string) => Record; + }, ): Record { - const result = typeof opts.toWire === "function" ? opts.toWire("anthropic") : {}; + const result = + typeof opts.toWire === "function" ? opts.toWire("anthropic") : {}; const mappings: Record = { maxOutputTokens: "max_tokens", @@ -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; } @@ -231,7 +241,11 @@ function schemaToWire(properties: unknown[]): Record { const props: Record = {}; const required: string[] = []; - for (const p of properties as Array<{ name?: string; required?: boolean } & Parameters[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); @@ -258,7 +272,8 @@ function propertyToJsonSchema(prop: { const schema: Record = 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 @@ -270,7 +285,9 @@ function propertyToJsonSchema(prop: { if (prop.properties) { const nested: Record = {}; 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); @@ -343,7 +360,9 @@ export function toolsToWire(agent: Agent): Record[] { 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).name as string)); + params = params.filter( + (p) => !boundNames.has((p as Record).name as string), + ); } tool.input_schema = schemaToWire(params); } else { diff --git a/runtime/typescript/packages/core/src/core/agent-events.ts b/runtime/typescript/packages/core/src/core/agent-events.ts index cc44f0301..a7515b88e 100644 --- a/runtime/typescript/packages/core/src/core/agent-events.ts +++ b/runtime/typescript/packages/core/src/core/agent-events.ts @@ -27,7 +27,10 @@ export type AgentEventType = | "compaction_failed"; /** Callback signature for agent loop events. */ -export type EventCallback = (eventType: AgentEventType, data: Record) => void; +export type EventCallback = ( + eventType: AgentEventType, + data: Record, +) => void; /** * Safely emit an event. Swallows errors from callback (spec §13.1: @@ -46,7 +49,8 @@ export function emitEvent( id: `evt_${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`, type: eventType, timestamp: new Date().toISOString(), - iteration: typeof data.iteration === "number" ? data.iteration : undefined, + iteration: + typeof data.iteration === "number" ? data.iteration : undefined, payload: data, }, }); diff --git a/runtime/typescript/packages/core/src/core/connections.ts b/runtime/typescript/packages/core/src/core/connections.ts index 5e8ed12b3..9b5cc53ec 100644 --- a/runtime/typescript/packages/core/src/core/connections.ts +++ b/runtime/typescript/packages/core/src/core/connections.ts @@ -31,7 +31,7 @@ export function getConnection(name: string): unknown { if (c === undefined) { throw new Error( `Connection "${name}" is not registered. ` + - `Call registerConnection("${name}", client) first.`, + `Call registerConnection("${name}", client) first.`, ); } return c; diff --git a/runtime/typescript/packages/core/src/core/context.ts b/runtime/typescript/packages/core/src/core/context.ts index 0617174a5..35676665b 100644 --- a/runtime/typescript/packages/core/src/core/context.ts +++ b/runtime/typescript/packages/core/src/core/context.ts @@ -48,7 +48,9 @@ export function summarizeDropped(messages: Message[]): string { if (Array.isArray(toolCalls)) { const names = toolCalls.map( (tc: Record) => - (tc.name as string) ?? ((tc.function as Record)?.name ?? "?"), + (tc.name as string) ?? + (tc.function as Record)?.name ?? + "?", ); lines.push(` Called tools: ${names.join(", ")}`); } @@ -83,8 +85,14 @@ export function formatDroppedMessages(messages: Message[]): string { if (Array.isArray(toolCalls)) { for (const tc of toolCalls) { const tcObj = tc as Record; - const name = (tcObj.name as string) ?? ((tcObj.function as Record)?.name ?? "?"); - const args = (tcObj.arguments as string) ?? ((tcObj.function as Record)?.arguments ?? ""); + const name = + (tcObj.name as string) ?? + (tcObj.function as Record)?.name ?? + "?"; + const args = + (tcObj.arguments as string) ?? + (tcObj.function as Record)?.arguments ?? + ""; lines.push(`Called: ${name}(${args})`); } } @@ -119,7 +127,10 @@ export function trimToContextWindow( const summaryBudget = Math.min(5000, Math.floor(budgetChars * 0.05)); const dropped: Message[] = []; - while (estimateChars([...systemMsgs, ...rest]) > budgetChars - summaryBudget && rest.length > 2) { + while ( + estimateChars([...systemMsgs, ...rest]) > budgetChars - summaryBudget && + rest.length > 2 + ) { dropped.push(rest.shift()!); } @@ -132,7 +143,12 @@ export function trimToContextWindow( if (droppedCount > 0) { const summaryText = summarizeDropped(dropped); if (summaryText) { - messages.push(new Message({ role: "user", parts: [{ kind: "text", value: summaryText }] })); + messages.push( + new Message({ + role: "user", + parts: [{ kind: "text", value: summaryText }], + }), + ); } } diff --git a/runtime/typescript/packages/core/src/core/guardrails.ts b/runtime/typescript/packages/core/src/core/guardrails.ts index 09c78a40d..88906b48a 100644 --- a/runtime/typescript/packages/core/src/core/guardrails.ts +++ b/runtime/typescript/packages/core/src/core/guardrails.ts @@ -27,7 +27,10 @@ export type InputGuardrail = (messages: Message[]) => GuardrailResult; /** Output guardrail hook signature. */ export type OutputGuardrail = (message: Message) => GuardrailResult; /** Tool guardrail hook signature. */ -export type ToolGuardrail = (name: string, args: Record) => GuardrailResult; +export type ToolGuardrail = ( + name: string, + args: Record, +) => GuardrailResult; /** Configuration for guardrail hooks. */ export interface GuardrailsOptions { diff --git a/runtime/typescript/packages/core/src/core/index.ts b/runtime/typescript/packages/core/src/core/index.ts index 725d300f5..421e6b04c 100644 --- a/runtime/typescript/packages/core/src/core/index.ts +++ b/runtime/typescript/packages/core/src/core/index.ts @@ -31,9 +31,18 @@ export { resilientJsonParse, extractFirstJsonBlock, } from "./tool-dispatch.js"; -export { type AgentEventType, type EventCallback, emitEvent } from "./agent-events.js"; +export { + type AgentEventType, + type EventCallback, + emitEvent, +} from "./agent-events.js"; export { CancelledError, checkCancellation } from "./cancellation.js"; -export { estimateChars, summarizeDropped, trimToContextWindow, formatDroppedMessages } from "./context.js"; +export { + estimateChars, + summarizeDropped, + trimToContextWindow, + formatDroppedMessages, +} from "./context.js"; export { type GuardrailResult, GuardrailError, @@ -44,7 +53,13 @@ export { Guardrails, } from "./guardrails.js"; export { Steering } from "./steering.js"; -export { tool, bindTools, type ToolOptions, type ToolParameter, type ToolFunction } from "./tool-decorator.js"; +export { + tool, + bindTools, + type ToolOptions, + type ToolParameter, + type ToolFunction, +} from "./tool-decorator.js"; export { type StructuredResult, StructuredResultSymbol, diff --git a/runtime/typescript/packages/core/src/core/interfaces.ts b/runtime/typescript/packages/core/src/core/interfaces.ts index d0041ff31..04e1748c7 100644 --- a/runtime/typescript/packages/core/src/core/interfaces.ts +++ b/runtime/typescript/packages/core/src/core/interfaces.ts @@ -21,7 +21,11 @@ import type { Message } from "./types.js"; * Discovered by: `agent.template.format.kind` (e.g., "nunjucks", "mustache"). */ export interface Renderer { - render(agent: Agent, template: string, inputs: Record): Promise; + render( + agent: Agent, + template: string, + inputs: Record, + ): Promise; } // --------------------------------------------------------------------------- @@ -36,7 +40,11 @@ export interface Renderer { * Optionally implements `preRender()` for nonce injection (strict mode). */ export interface Parser { - parse(agent: Agent, rendered: string, context?: Record): Promise; + parse( + agent: Agent, + rendered: string, + context?: Record, + ): Promise; /** * Optional hook called before rendering to sanitize the template diff --git a/runtime/typescript/packages/core/src/core/loader.ts b/runtime/typescript/packages/core/src/core/loader.ts index 0ad8c2ac0..2d153a11c 100644 --- a/runtime/typescript/packages/core/src/core/loader.ts +++ b/runtime/typescript/packages/core/src/core/loader.ts @@ -77,7 +77,11 @@ export function defaultSaveContext( // Internal pipeline // --------------------------------------------------------------------------- -function buildAgent(raw: string, filePath: string, options: LoadOptions): Agent { +function buildAgent( + raw: string, + filePath: string, + options: LoadOptions, +): Agent { // 1. Split frontmatter + body const { data, content } = matter(raw, { engines: { @@ -94,7 +98,9 @@ function buildAgent(raw: string, filePath: string, options: LoadOptions): Agent // 2. Load via Prompty.load() with preProcess for ${protocol:value} expansion const ctx = new LoadContext({ - preProcess: makePreProcess(filePath, options) as (data: Record) => Record, + preProcess: makePreProcess(filePath, options) as ( + data: Record, + ) => Record, }); const agent = Agent.load(frontmatter, ctx); @@ -127,11 +133,16 @@ function rejectExecutableFrontmatter(): never { * File references are limited to the prompt directory by default. Callers may * provide additional allowed roots via `allowedFileRoots`. */ -function makePreProcess(agentFile: string, options: LoadOptions): (data: unknown) => unknown { +function makePreProcess( + agentFile: string, + options: LoadOptions, +): (data: unknown) => unknown { const agentDir = realpathSync(dirname(agentFile)); const allowedRoots = [ agentDir, - ...(options.allowedFileRoots ?? []).map((root) => canonicalizeExistingPath(resolve(root))), + ...(options.allowedFileRoots ?? []).map((root) => + canonicalizeExistingPath(resolve(root)), + ), ]; return (data: unknown): unknown => { @@ -141,7 +152,11 @@ function makePreProcess(agentFile: string, options: LoadOptions): (data: unknown const record = data as Record; for (const [key, value] of Object.entries(record)) { - if (typeof value !== "string" || !value.startsWith("${") || !value.endsWith("}")) { + if ( + typeof value !== "string" || + !value.startsWith("${") || + !value.endsWith("}") + ) { continue; } @@ -156,7 +171,8 @@ function makePreProcess(agentFile: string, options: LoadOptions): (data: unknown // Support ${env:VAR:default} const nextColon = val.indexOf(":"); const varName = nextColon === -1 ? val : val.slice(0, nextColon); - const defaultVal = nextColon === -1 ? undefined : val.slice(nextColon + 1); + const defaultVal = + nextColon === -1 ? undefined : val.slice(nextColon + 1); const envVal = process.env[varName]; if (envVal !== undefined) { @@ -178,15 +194,24 @@ function makePreProcess(agentFile: string, options: LoadOptions): (data: unknown }; } -function resolveFileReference(agentDir: string, reference: string, allowedRoots: string[], key: string): string { - const candidate = isAbsolute(reference) ? resolve(reference) : resolve(agentDir, reference); +function resolveFileReference( + agentDir: string, + reference: string, + allowedRoots: string[], + key: string, +): string { + const candidate = isAbsolute(reference) + ? resolve(reference) + : resolve(agentDir, reference); if (!isWithinAnyRoot(candidate, allowedRoots)) { throw new Error( `File reference '${reference}' resolves outside allowed roots for key '${key}'. Allowed roots: ${allowedRoots.join(", ")}`, ); } if (!existsSync(candidate)) { - throw new Error(`Referenced file '${reference}' not found for key '${key}' (resolved to ${candidate})`); + throw new Error( + `Referenced file '${reference}' not found for key '${key}' (resolved to ${candidate})`, + ); } const resolved = realpathSync(candidate); diff --git a/runtime/typescript/packages/core/src/core/pipeline.ts b/runtime/typescript/packages/core/src/core/pipeline.ts index 108c1fba5..a31f59a73 100644 --- a/runtime/typescript/packages/core/src/core/pipeline.ts +++ b/runtime/typescript/packages/core/src/core/pipeline.ts @@ -41,7 +41,12 @@ import { dictToMessage, text, } from "./types.js"; -import { getRenderer, getParser, getExecutor, getProcessor } from "./registry.js"; +import { + getRenderer, + getParser, + getExecutor, + getProcessor, +} from "./registry.js"; import { getLastNonces, clearLastNonces } from "../renderers/common.js"; import { traceSpan, sanitizeValue } from "../tracing/tracer.js"; import { load } from "./loader.js"; @@ -132,7 +137,7 @@ function sanitizeNonces(value: unknown): unknown { } if (Array.isArray(value)) { - return value.map(v => sanitizeNonces(v)); + return value.map((v) => sanitizeNonces(v)); } if (typeof value === "object" && value !== null) { @@ -214,23 +219,26 @@ function serializeAgent(agent: Agent): Record { provider: model?.provider ?? "", connection: model?.connection ?? {}, }, - inputs: agent.inputs?.map(p => ({ - name: p.name ?? "", - kind: p.kind ?? "", - description: p.description ?? "", - required: p.required ?? false, - default: p.default, - example: p.example, - })) ?? [], - outputs: agent.outputs?.map(p => ({ - name: p.name ?? "", - kind: p.kind ?? "", - description: p.description ?? "", - })) ?? [], - tools: agent.tools?.map(t => ({ - name: t.name ?? "", - kind: t.kind ?? "", - })) ?? [], + inputs: + agent.inputs?.map((p) => ({ + name: p.name ?? "", + kind: p.kind ?? "", + description: p.description ?? "", + required: p.required ?? false, + default: p.default, + example: p.example, + })) ?? [], + outputs: + agent.outputs?.map((p) => ({ + name: p.name ?? "", + kind: p.kind ?? "", + description: p.description ?? "", + })) ?? [], + tools: + agent.tools?.map((t) => ({ + name: t.name ?? "", + kind: t.kind ?? "", + })) ?? [], template: { format: agent.template?.format?.kind ?? DEFAULT_FORMAT, parser: agent.template?.parser?.kind ?? DEFAULT_PARSER, @@ -241,7 +249,7 @@ function serializeAgent(agent: Agent): Record { /** Serialize messages for trace output, matching Python's parser result. */ function serializeMessages(messages: Message[]): unknown[] { - return messages.map(m => ({ + return messages.map((m) => ({ role: m.role, content: m.text, })); @@ -266,7 +274,10 @@ export async function render( return traceSpan(renderer.constructor?.name ?? "Renderer", async (emit) => { const template = agent.instructions ?? ""; - emit("signature", `prompty.renderers.${renderer.constructor?.name ?? "Renderer"}.render`); + emit( + "signature", + `prompty.renderers.${renderer.constructor?.name ?? "Renderer"}.render`, + ); emit("inputs", { data: inputs }); const result = await renderer.render(agent, template, inputs); emit("result", sanitizeNonces(result)); @@ -288,7 +299,10 @@ export async function parse( const parser = getParser(parserKind); return traceSpan(parser.constructor?.name ?? "Parser", async (emit) => { - emit("signature", `prompty.parsers.${parser.constructor?.name ?? "Parser"}.parse`); + emit( + "signature", + `prompty.parsers.${parser.constructor?.name ?? "Parser"}.parse`, + ); emit("inputs", sanitizeNonces(rendered)); const messages = await parser.parse(agent, rendered, context); emit("result", sanitizeNonces(serializeMessages(messages))); @@ -461,16 +475,17 @@ export async function invoke( options?: InvokeOptions & { validator?: (data: unknown) => T }, ): Promise { return traceSpan("invoke", async (emit) => { - const agent = typeof prompt === "string" - ? await traceSpan("load", async (loadEmit) => { - loadEmit("signature", "prompty.load"); - loadEmit("description", "Load a prompty file."); - loadEmit("inputs", { prompty_file: prompt }); - const loaded = load(prompt); - loadEmit("result", serializeAgent(loaded)); - return loaded; - }) - : prompt; + const agent = + typeof prompt === "string" + ? await traceSpan("load", async (loadEmit) => { + loadEmit("signature", "prompty.load"); + loadEmit("description", "Load a prompty file."); + loadEmit("inputs", { prompty_file: prompt }); + const loaded = load(prompt); + loadEmit("result", serializeAgent(loaded)); + return loaded; + }) + : prompt; emit("signature", "prompty.invoke"); emit("description", "Invoke a prompty"); @@ -550,7 +565,9 @@ async function invokeWithRetry( if (signal) { const onAbort = () => { clearTimeout(timer); - reject(new CancelledError("Operation cancelled during retry backoff")); + reject( + new CancelledError("Operation cancelled during retry backoff"), + ); }; signal.addEventListener("abort", onAbort, { once: true }); } @@ -604,11 +621,16 @@ function replaceSummaryMessage(messages: Message[], summary: string): void { (m) => m.role === "user" && m.parts.some( - (p) => p.kind === "text" && (p as { value: string }).value.includes("[Context summary:"), + (p) => + p.kind === "text" && + (p as { value: string }).value.includes("[Context summary:"), ), ); if (idx >= 0) { - messages[idx] = new Message({ role: "user", parts: [text(`[Context summary: ${summary}]`)] }); + messages[idx] = new Message({ + role: "user", + parts: [text(`[Context summary: ${summary}]`)], + }); } } @@ -632,12 +654,16 @@ async function applyCompaction( } else { const result = compaction(dropped); summary = - typeof result === "string" ? result : String(await (result as Promise)); + typeof result === "string" + ? result + : String(await (result as Promise)); } if (summary && summary.trim()) { replaceSummaryMessage(messages, summary); - emitEvent(onEvent, "compaction_complete", { summary_length: summary.length }); + emitEvent(onEvent, "compaction_complete", { + summary_length: summary.length, + }); } else { emitEvent(onEvent, "compaction_failed", { reason: "empty result" }); } @@ -688,16 +714,17 @@ export async function turn( ): Promise { const label = options?.turn != null ? `turn ${options.turn}` : "turn"; const rawResult = await traceSpan(label, async (emit) => { - const agent = typeof prompt === "string" - ? await traceSpan("load", async (loadEmit) => { - loadEmit("signature", "prompty.load"); - loadEmit("description", "Load a prompty file."); - loadEmit("inputs", { prompty_file: prompt }); - const loaded = load(prompt); - loadEmit("result", serializeAgent(loaded)); - return loaded; - }) - : prompt; + const agent = + typeof prompt === "string" + ? await traceSpan("load", async (loadEmit) => { + loadEmit("signature", "prompty.load"); + loadEmit("description", "Load a prompty file."); + loadEmit("inputs", { prompty_file: prompt }); + const loaded = load(prompt); + loadEmit("result", serializeAgent(loaded)); + return loaded; + }) + : prompt; emit("signature", "prompty.turn"); emit("description", label); @@ -733,11 +760,19 @@ export async function turn( // §13.3 — Trim context window if (options?.contextBudget !== undefined) { - const [droppedCount, droppedMsgs] = trimToContextWindow(messages, options.contextBudget); + const [droppedCount, droppedMsgs] = trimToContextWindow( + messages, + options.contextBudget, + ); if (droppedCount > 0) { emitEvent(onEvent, "messages_updated", { messages }); if (options.compaction != null) { - await applyCompaction(options.compaction, droppedMsgs, messages, onEvent); + await applyCompaction( + options.compaction, + droppedMsgs, + messages, + onEvent, + ); } } } @@ -746,8 +781,14 @@ export async function turn( if (options?.guardrails) { const result = options.guardrails.checkInput(messages); if (!result.allowed) { - emitEvent(onEvent, "error", { message: `Input guardrail denied: ${result.reason}` }); - emitFailedTurnEnd(onEvent, new GuardrailError(result.reason ?? "Input guardrail denied"), 0); + emitEvent(onEvent, "error", { + message: `Input guardrail denied: ${result.reason}`, + }); + emitFailedTurnEnd( + onEvent, + new GuardrailError(result.reason ?? "Input guardrail denied"), + 0, + ); throw new GuardrailError(result.reason ?? "Input guardrail denied"); } if (result.rewrite) messages = result.rewrite; @@ -781,7 +822,11 @@ export async function turn( if (options?.raw) { emit("result", response); - emitEvent(onEvent, "turn_end", { iterations: 0, status: "success", response }); + emitEvent(onEvent, "turn_end", { + iterations: 0, + status: "success", + response, + }); return response; } let processed: unknown; @@ -809,25 +854,44 @@ export async function turn( // §13.4 — Output guardrail on final response if (options?.guardrails) { - const contentStr = typeof processed === "string" ? processed : JSON.stringify(processed); - const assistantMsg = new Message({ role: "assistant", parts: [text(contentStr)] }); + const contentStr = + typeof processed === "string" ? processed : JSON.stringify(processed); + const assistantMsg = new Message({ + role: "assistant", + parts: [text(contentStr)], + }); const gr = options.guardrails.checkOutput(assistantMsg); if (!gr.allowed) { - emitEvent(onEvent, "error", { message: `Output guardrail denied: ${gr.reason}` }); - emitFailedTurnEnd(onEvent, new GuardrailError(gr.reason ?? "Output guardrail denied"), 0, processed); + emitEvent(onEvent, "error", { + message: `Output guardrail denied: ${gr.reason}`, + }); + emitFailedTurnEnd( + onEvent, + new GuardrailError(gr.reason ?? "Output guardrail denied"), + 0, + processed, + ); throw new GuardrailError(gr.reason ?? "Output guardrail denied"); } if (gr.rewrite !== undefined) { emit("result", gr.rewrite); emitEvent(onEvent, "done", { response: gr.rewrite, messages }); - emitEvent(onEvent, "turn_end", { iterations: 0, status: "success", response: gr.rewrite }); + emitEvent(onEvent, "turn_end", { + iterations: 0, + status: "success", + response: gr.rewrite, + }); return gr.rewrite; } } emit("result", sanitizeValue("result", processed)); emitEvent(onEvent, "done", { response: processed, messages }); - emitEvent(onEvent, "turn_end", { iterations: 0, status: "success", response: processed }); + emitEvent(onEvent, "turn_end", { + iterations: 0, + status: "success", + response: processed, + }); return processed; } @@ -870,18 +934,30 @@ export async function turn( if (pending.length > 0) { messages.push(...pending); emitEvent(onEvent, "messages_updated", { messages }); - emitEvent(onEvent, "status", { message: `Injected ${pending.length} steering message(s)` }); + emitEvent(onEvent, "status", { + message: `Injected ${pending.length} steering message(s)`, + }); } } // §13.3 — Trim context window if (contextBudget !== undefined) { - const [droppedCount, droppedMsgs] = trimToContextWindow(messages, contextBudget); + const [droppedCount, droppedMsgs] = trimToContextWindow( + messages, + contextBudget, + ); if (droppedCount > 0) { emitEvent(onEvent, "messages_updated", { messages }); - emitEvent(onEvent, "status", { message: `Trimmed ${droppedCount} messages for context budget` }); + emitEvent(onEvent, "status", { + message: `Trimmed ${droppedCount} messages for context budget`, + }); if (options?.compaction != null) { - await applyCompaction(options.compaction, droppedMsgs, messages, onEvent); + await applyCompaction( + options.compaction, + droppedMsgs, + messages, + onEvent, + ); } } } @@ -890,8 +966,14 @@ export async function turn( if (guardrails) { const result = guardrails.checkInput(messages); if (!result.allowed) { - emitEvent(onEvent, "error", { message: `Input guardrail denied: ${result.reason}` }); - emitFailedTurnEnd(onEvent, new GuardrailError(result.reason ?? "Input guardrail denied"), iteration); + emitEvent(onEvent, "error", { + message: `Input guardrail denied: ${result.reason}`, + }); + emitFailedTurnEnd( + onEvent, + new GuardrailError(result.reason ?? "Input guardrail denied"), + iteration, + ); throw new GuardrailError(result.reason ?? "Input guardrail denied"); } if (result.rewrite) messages = result.rewrite; @@ -915,7 +997,14 @@ export async function turn( iteration, }); try { - response = await invokeWithRetry(executor, agent, messages, maxLlmRetries, onEvent, signal); + response = await invokeWithRetry( + executor, + agent, + messages, + maxLlmRetries, + onEvent, + signal, + ); } catch (err) { emitFailedTurnEnd(onEvent, err, iteration); throw err; @@ -945,11 +1034,21 @@ export async function turn( // §13.4 — Output guardrail if (guardrails && content) { - const assistantMsg = new Message({ role: "assistant", parts: [text(content)] }); + const assistantMsg = new Message({ + role: "assistant", + parts: [text(content)], + }); const gr = guardrails.checkOutput(assistantMsg); if (!gr.allowed) { - emitEvent(onEvent, "error", { message: `Output guardrail denied: ${gr.reason}` }); - emitFailedTurnEnd(onEvent, new GuardrailError(gr.reason ?? "Output guardrail denied"), iteration, content); + emitEvent(onEvent, "error", { + message: `Output guardrail denied: ${gr.reason}`, + }); + emitFailedTurnEnd( + onEvent, + new GuardrailError(gr.reason ?? "Output guardrail denied"), + iteration, + content, + ); throw new GuardrailError(gr.reason ?? "Output guardrail denied"); } } @@ -958,16 +1057,24 @@ export async function turn( emit("iterations", iteration); emit("result", content); emitEvent(onEvent, "done", { response: content, messages }); - emitEvent(onEvent, "turn_end", { iterations: iteration, status: "success", response: content }); + emitEvent(onEvent, "turn_end", { + iterations: iteration, + status: "success", + response: content, + }); return content; } iteration++; if (iteration > maxIterations) { - emitFailedTurnEnd(onEvent, new Error("Agent loop exceeded maxIterations"), iteration); + emitFailedTurnEnd( + onEvent, + new Error("Agent loop exceeded maxIterations"), + iteration, + ); throw new Error( `Agent loop exceeded maxIterations (${maxIterations}). ` + - `The model kept requesting tool calls. Increase maxIterations or check your tools.`, + `The model kept requesting tool calls. Increase maxIterations or check your tools.`, ); } @@ -977,10 +1084,24 @@ export async function turn( toolEmit("signature", "prompty.turn.toolCalls"); toolEmit("description", `Tool call round ${iteration}`); const result = await buildToolMessagesFromCallsWithExtensions( - toolCalls, content, tools, agent, parentInputs, toolEmit, + toolCalls, + content, + tools, + agent, + parentInputs, + toolEmit, { onEvent, signal, guardrails, parallel: parallelToolCalls }, ); - toolEmit("result", result.map((m) => ({ role: m.role, content: m.parts.map((p) => (p as { value?: string }).value ?? "").join(""), metadata: m.metadata }))); + toolEmit( + "result", + result.map((m) => ({ + role: m.role, + content: m.parts + .map((p) => (p as { value?: string }).value ?? "") + .join(""), + metadata: m.metadata, + })), + ); return result; }); } catch (err) { @@ -997,32 +1118,55 @@ export async function turn( if (!hasToolCalls(response)) { let finalResult: unknown; try { - finalResult = options?.raw ? response : await process(agent, response); + finalResult = options?.raw + ? response + : await process(agent, response); } catch (err) { emitFailedTurnEnd(onEvent, err, iteration, response); throw err; } if (guardrails) { - const contentStr = typeof finalResult === "string" ? finalResult : JSON.stringify(finalResult); - const assistantMsg = new Message({ role: "assistant", parts: [text(contentStr)] }); + const contentStr = + typeof finalResult === "string" + ? finalResult + : JSON.stringify(finalResult); + const assistantMsg = new Message({ + role: "assistant", + parts: [text(contentStr)], + }); const gr = guardrails.checkOutput(assistantMsg); if (!gr.allowed) { - emitEvent(onEvent, "error", { message: `Output guardrail denied: ${gr.reason}` }); - emitFailedTurnEnd(onEvent, new GuardrailError(gr.reason ?? "Output guardrail denied"), iteration, finalResult); + emitEvent(onEvent, "error", { + message: `Output guardrail denied: ${gr.reason}`, + }); + emitFailedTurnEnd( + onEvent, + new GuardrailError(gr.reason ?? "Output guardrail denied"), + iteration, + finalResult, + ); throw new GuardrailError(gr.reason ?? "Output guardrail denied"); } if (gr.rewrite !== undefined) { emit("iterations", iteration); emit("result", gr.rewrite); emitEvent(onEvent, "done", { response: gr.rewrite, messages }); - emitEvent(onEvent, "turn_end", { iterations: iteration, status: "success", response: gr.rewrite }); + emitEvent(onEvent, "turn_end", { + iterations: iteration, + status: "success", + response: gr.rewrite, + }); return gr.rewrite; } } emit("iterations", iteration); emit("result", finalResult); emitEvent(onEvent, "done", { response: finalResult, messages }); - emitEvent(onEvent, "turn_end", { iterations: iteration, status: "success", response: finalResult }); + emitEvent(onEvent, "turn_end", { + iterations: iteration, + status: "success", + response: finalResult, + }); return finalResult; } @@ -1030,11 +1174,21 @@ export async function turn( if (guardrails) { const { textContent } = extractToolInfo(response); if (textContent) { - const assistantMsg = new Message({ role: "assistant", parts: [text(textContent)] }); + const assistantMsg = new Message({ + role: "assistant", + parts: [text(textContent)], + }); const gr = guardrails.checkOutput(assistantMsg); if (!gr.allowed) { - emitEvent(onEvent, "error", { message: `Output guardrail denied: ${gr.reason}` }); - emitFailedTurnEnd(onEvent, new GuardrailError(gr.reason ?? "Output guardrail denied"), iteration, textContent); + emitEvent(onEvent, "error", { + message: `Output guardrail denied: ${gr.reason}`, + }); + emitFailedTurnEnd( + onEvent, + new GuardrailError(gr.reason ?? "Output guardrail denied"), + iteration, + textContent, + ); throw new GuardrailError(gr.reason ?? "Output guardrail denied"); } } @@ -1042,10 +1196,14 @@ export async function turn( iteration++; if (iteration > maxIterations) { - emitFailedTurnEnd(onEvent, new Error("Agent loop exceeded maxIterations"), iteration); + emitFailedTurnEnd( + onEvent, + new Error("Agent loop exceeded maxIterations"), + iteration, + ); throw new Error( `Agent loop exceeded maxIterations (${maxIterations}). ` + - `The model kept requesting tool calls. Increase maxIterations or check your tools.`, + `The model kept requesting tool calls. Increase maxIterations or check your tools.`, ); } @@ -1055,10 +1213,23 @@ export async function turn( toolEmit("signature", "prompty.turn.toolCalls"); toolEmit("description", `Tool call round ${iteration}`); const result = await buildToolResultMessagesWithExtensions( - response, tools, agent, parentInputs, toolEmit, + response, + tools, + agent, + parentInputs, + toolEmit, { onEvent, signal, guardrails, parallel: parallelToolCalls }, ); - toolEmit("result", result.map((m) => ({ role: m.role, content: m.parts.map((p) => (p as { value?: string }).value ?? "").join(""), metadata: m.metadata }))); + toolEmit( + "result", + result.map((m) => ({ + role: m.role, + content: m.parts + .map((p) => (p as { value?: string }).value ?? "") + .join(""), + metadata: m.metadata, + })), + ); return result; }); } catch (err) { @@ -1095,7 +1266,8 @@ export function resolveBindings( if (!parentInputs || !agent.tools || agent.tools.length === 0) return args; const toolDef = agent.tools.find((t) => t.name === toolName); - if (!toolDef || !toolDef.bindings || toolDef.bindings.length === 0) return args; + if (!toolDef || !toolDef.bindings || toolDef.bindings.length === 0) + return args; const merged = { ...args }; for (const binding of toolDef.bindings) { @@ -1112,7 +1284,9 @@ export function resolveBindings( /** Check if a value is an async iterable (i.e. a stream). */ function isAsyncIterable(value: unknown): value is AsyncIterable { - return value != null && typeof value === "object" && Symbol.asyncIterator in value; + return ( + value != null && typeof value === "object" && Symbol.asyncIterator in value + ); } /** Check if an item looks like a ToolCall from the processor. */ @@ -1169,13 +1343,16 @@ function streamFailureFrom(item: unknown): StreamFailure | undefined { } const chunk = item as Record; - if (chunk.kind !== "failure" || typeof chunk.failure !== "object" || chunk.failure === null) { + if ( + chunk.kind !== "failure" || + typeof chunk.failure !== "object" || + chunk.failure === null + ) { return undefined; } return StreamFailure.load(chunk.failure as Record); } - // --------------------------------------------------------------------------- // Thread marker helpers // --------------------------------------------------------------------------- @@ -1212,10 +1389,18 @@ function expandThreads( if (part.value.includes(nonce)) { // Split text around the nonce const before = part.value.slice(0, part.value.indexOf(nonce)).trim(); - const after = part.value.slice(part.value.indexOf(nonce) + nonce.length).trim(); + const after = part.value + .slice(part.value.indexOf(nonce) + nonce.length) + .trim(); if (before) { - result.push(new Message({ role: msg.role, parts: [text(before)], metadata: { ...msg.metadata } })); + result.push( + new Message({ + role: msg.role, + parts: [text(before)], + metadata: { ...msg.metadata }, + }), + ); } // Insert thread messages from input @@ -1231,7 +1416,13 @@ function expandThreads( } if (after) { - result.push(new Message({ role: msg.role, parts: [text(after)], metadata: { ...msg.metadata } })); + result.push( + new Message({ + role: msg.role, + parts: [text(after)], + metadata: { ...msg.metadata }, + }), + ); } expanded = true; @@ -1291,7 +1482,12 @@ function hasToolCalls(response: unknown): boolean { * Returns a uniform array of `{id, name, arguments}` and any text content. */ function extractToolInfo(response: unknown): { - toolCalls: { id: string; name: string; arguments: string; [key: string]: string }[]; + toolCalls: { + id: string; + name: string; + arguments: string; + [key: string]: string; + }[]; textContent: string; } { if (typeof response !== "object" || response === null) { @@ -1322,8 +1518,8 @@ function extractToolInfo(response: unknown): { (item) => item.type === "function_call", ); const toolCalls = funcCalls.map((fc) => ({ - id: ((fc.call_id ?? fc.id ?? "") as string), - call_id: ((fc.call_id ?? fc.id ?? "") as string), + id: (fc.call_id ?? fc.id ?? "") as string, + call_id: (fc.call_id ?? fc.id ?? "") as string, name: fc.name as string, arguments: (fc.arguments as string) ?? "{}", })); @@ -1336,14 +1532,16 @@ function extractToolInfo(response: unknown): { const choice = choices[0] as Record; const message = choice.message as Record | undefined; if (message && Array.isArray(message.tool_calls)) { - const toolCalls = (message.tool_calls as Record[]).map((tc) => { - const fn = tc.function as Record; - return { - id: tc.id as string, - name: fn.name as string, - arguments: fn.arguments as string, - }; - }); + const toolCalls = (message.tool_calls as Record[]).map( + (tc) => { + const fn = tc.function as Record; + return { + id: tc.id as string, + name: fn.name as string, + arguments: fn.arguments as string, + }; + }, + ); return { toolCalls, textContent: (message.content as string) ?? "" }; } } @@ -1384,7 +1582,11 @@ async function dispatchOneToolWithExtensions( } // §13.1 — Emit tool_call_start - emitEvent(onEvent, "tool_call_start", { id: tc.id, name: tc.name, arguments: tc.arguments }); + emitEvent(onEvent, "tool_call_start", { + id: tc.id, + name: tc.name, + arguments: tc.arguments, + }); const started = performance.now(); // §13.4 — Tool guardrail @@ -1405,7 +1607,13 @@ async function dispatchOneToolWithExtensions( return deniedMsg; } if (gr.rewrite !== undefined) { - tc = { ...tc, arguments: typeof gr.rewrite === "string" ? gr.rewrite : JSON.stringify(gr.rewrite) }; + tc = { + ...tc, + arguments: + typeof gr.rewrite === "string" + ? gr.rewrite + : JSON.stringify(gr.rewrite), + }; } } @@ -1417,7 +1625,10 @@ async function dispatchOneToolWithExtensions( parsedArgs = resilientJsonParse(tc.arguments); if (parsedArgs === null) { result = `Error: Tool '${tc.name}' received unparseable arguments`; - emitEvent(onEvent, "error", { tool: tc.name, error: "Unparseable tool arguments" }); + emitEvent(onEvent, "error", { + tool: tc.name, + error: "Unparseable tool arguments", + }); emitEvent(onEvent, "tool_result", { name: tc.name, result }); emitEvent(onEvent, "tool_call_complete", { id: tc.id, @@ -1429,17 +1640,34 @@ async function dispatchOneToolWithExtensions( }); return result; } - if (agent && parentInputs && typeof parsedArgs === "object" && parsedArgs !== null && !Array.isArray(parsedArgs)) { - parsedArgs = resolveBindings(agent, tc.name, parsedArgs as Record, parentInputs); + if ( + agent && + parentInputs && + typeof parsedArgs === "object" && + parsedArgs !== null && + !Array.isArray(parsedArgs) + ) { + parsedArgs = resolveBindings( + agent, + tc.name, + parsedArgs as Record, + parentInputs, + ); } - result = await traceSpan(tc.name, async (toolEmit) => { + result = (await traceSpan(tc.name, async (toolEmit) => { toolEmit("signature", `prompty.tool.${tc.name}`); toolEmit("description", `Execute tool: ${tc.name}`); toolEmit("inputs", { arguments: parsedArgs, id: tc.id }); - const r = await dispatchTool(tc.name, parsedArgs as Record, tools, agent, parentInputs); + const r = await dispatchTool( + tc.name, + parsedArgs as Record, + tools, + agent, + parentInputs, + ); toolEmit("result", r); return r; - }) as string; + })) as string; } catch (err) { // Re-throw cancellation errors if (err instanceof CancelledError) throw err; @@ -1472,7 +1700,12 @@ async function dispatchOneToolWithExtensions( * Dispatch tool calls with §13 extensions, supporting parallel execution. */ async function dispatchToolsWithExtensions( - toolCalls: { id: string; name: string; arguments: string; [key: string]: string }[], + toolCalls: { + id: string; + name: string; + arguments: string; + [key: string]: string; + }[], tools: Record unknown>, agent: Agent, parentInputs: Record, @@ -1481,14 +1714,18 @@ async function dispatchToolsWithExtensions( if (ext.parallel && toolCalls.length > 1) { // §13.6 — Parallel tool execution via Promise.all return Promise.all( - toolCalls.map((tc) => dispatchOneToolWithExtensions(tc, tools, agent, parentInputs, ext)), + toolCalls.map((tc) => + dispatchOneToolWithExtensions(tc, tools, agent, parentInputs, ext), + ), ); } // Sequential execution const results: string[] = []; for (const tc of toolCalls) { - results.push(await dispatchOneToolWithExtensions(tc, tools, agent, parentInputs, ext)); + results.push( + await dispatchOneToolWithExtensions(tc, tools, agent, parentInputs, ext), + ); } return results; } @@ -1506,17 +1743,33 @@ async function buildToolResultMessagesWithExtensions( ): Promise { const { toolCalls, textContent } = extractToolInfo(response); - const toolResults = await dispatchToolsWithExtensions(toolCalls, tools, agent, parentInputs, ext); + const toolResults = await dispatchToolsWithExtensions( + toolCalls, + tools, + agent, + parentInputs, + ext, + ); if (parentEmit) { parentEmit("inputs", { - tool_calls: toolCalls.map((tc, i) => ({ name: tc.name, arguments: tc.arguments, id: tc.id, result: toolResults[i] })), + tool_calls: toolCalls.map((tc, i) => ({ + name: tc.name, + arguments: tc.arguments, + id: tc.id, + result: toolResults[i], + })), }); } const provider = resolveProvider(agent); const executor = getExecutor(provider); - return executor.formatToolMessages(response, toolCalls, toolResults, textContent); + return executor.formatToolMessages( + response, + toolCalls, + toolResults, + textContent, + ); } /** @@ -1531,17 +1784,37 @@ async function buildToolMessagesFromCallsWithExtensions( parentEmit: ((key: string, value: unknown) => void) | undefined, ext: ToolExtensionOptions, ): Promise { - const normalizedCalls = toolCalls.map((tc) => ({ id: tc.id, name: tc.name, arguments: tc.arguments })); + const normalizedCalls = toolCalls.map((tc) => ({ + id: tc.id, + name: tc.name, + arguments: tc.arguments, + })); - const toolResults = await dispatchToolsWithExtensions(normalizedCalls, tools, agent, parentInputs, ext); + const toolResults = await dispatchToolsWithExtensions( + normalizedCalls, + tools, + agent, + parentInputs, + ext, + ); if (parentEmit) { parentEmit("inputs", { - tool_calls: normalizedCalls.map((tc, i) => ({ name: tc.name, arguments: tc.arguments, id: tc.id, result: toolResults[i] })), + tool_calls: normalizedCalls.map((tc, i) => ({ + name: tc.name, + arguments: tc.arguments, + id: tc.id, + result: toolResults[i], + })), }); } const provider = resolveProvider(agent); const executor = getExecutor(provider); - return executor.formatToolMessages(null, normalizedCalls, toolResults, textContent); + return executor.formatToolMessages( + null, + normalizedCalls, + toolResults, + textContent, + ); } diff --git a/runtime/typescript/packages/core/src/core/registry.ts b/runtime/typescript/packages/core/src/core/registry.ts index 52b5e150d..dadd0ca73 100644 --- a/runtime/typescript/packages/core/src/core/registry.ts +++ b/runtime/typescript/packages/core/src/core/registry.ts @@ -52,8 +52,8 @@ export class InvokerError extends Error { ) { super( `No ${group} registered for key "${key}". ` + - `Register one with register${group.charAt(0).toUpperCase() + group.slice(1)}("${key}", impl) ` + - `or install a package that provides it.`, + `Register one with register${group.charAt(0).toUpperCase() + group.slice(1)}("${key}", impl) ` + + `or install a package that provides it.`, ); this.name = "InvokerError"; } diff --git a/runtime/typescript/packages/core/src/core/steering.ts b/runtime/typescript/packages/core/src/core/steering.ts index 258d45794..5145dc93c 100644 --- a/runtime/typescript/packages/core/src/core/steering.ts +++ b/runtime/typescript/packages/core/src/core/steering.ts @@ -20,7 +20,10 @@ export class Steering { /** Remove and return all queued messages as Message objects. */ drain(): Message[] { const items = this.queue.splice(0); - return items.map((text) => new Message({ role: "user", parts: [{ kind: "text", value: text }] })); + return items.map( + (text) => + new Message({ role: "user", parts: [{ kind: "text", value: text }] }), + ); } /** Whether there are pending messages without consuming them. */ diff --git a/runtime/typescript/packages/core/src/core/tool-decorator.ts b/runtime/typescript/packages/core/src/core/tool-decorator.ts index 585fd0943..5d2b236ae 100644 --- a/runtime/typescript/packages/core/src/core/tool-decorator.ts +++ b/runtime/typescript/packages/core/src/core/tool-decorator.ts @@ -33,7 +33,9 @@ export interface ToolParameter { } /** Extended function with __tool__ metadata. */ -export interface ToolFunction unknown = (...args: unknown[]) => unknown> { +export interface ToolFunction< + T extends (...args: unknown[]) => unknown = (...args: unknown[]) => unknown, +> { (...args: Parameters): ReturnType; __tool__: FunctionTool; } @@ -71,14 +73,15 @@ export function tool unknown>( const toolDesc = options?.description ?? ""; const shouldRegister = options?.register !== false; - const properties: Property[] = (options?.parameters ?? []).map((p) => - new Property({ - name: p.name, - kind: p.kind ?? "string", - required: p.required ?? (p.default === undefined), - description: p.description, - default: p.default, - }), + const properties: Property[] = (options?.parameters ?? []).map( + (p) => + new Property({ + name: p.name, + kind: p.kind ?? "string", + required: p.required ?? p.default === undefined, + description: p.description, + default: p.default, + }), ); const toolDef = new FunctionTool({ diff --git a/runtime/typescript/packages/core/src/core/tool-dispatch.ts b/runtime/typescript/packages/core/src/core/tool-dispatch.ts index 9bdddf04c..4b0bc8003 100644 --- a/runtime/typescript/packages/core/src/core/tool-dispatch.ts +++ b/runtime/typescript/packages/core/src/core/tool-dispatch.ts @@ -176,7 +176,8 @@ class PromptyToolHandler implements ToolHandler { const { load } = await import("./loader.js"); const { prepare, run, turn } = await import("./pipeline.js"); - const parentPath = (agent.metadata ?? {}).__source_path as string | undefined; + const parentPath = (agent.metadata ?? {}).__source_path as + string | undefined; if (!parentPath) { return `Error: cannot resolve PromptyTool '${tool.name}': parent has no __source_path`; } @@ -184,9 +185,14 @@ class PromptyToolHandler implements ToolHandler { const childPath = resolve(dirname(parentPath), tool.path as string); // Circular reference detection - const stack = ((agent.metadata ?? {}).__prompty_tool_stack as string[] | undefined) ?? []; + const stack = + ((agent.metadata ?? {}).__prompty_tool_stack as string[] | undefined) ?? + []; const normalizedChild = resolve(childPath); - const visited = new Set([...stack.map((p) => resolve(p)), resolve(parentPath)]); + const visited = new Set([ + ...stack.map((p) => resolve(p)), + resolve(parentPath), + ]); if (visited.has(normalizedChild)) { const chain = [...stack, parentPath, childPath].join(" → "); return `Error executing PromptyTool '${tool.name}': circular reference detected: ${chain}`; @@ -299,11 +305,14 @@ export function extractFirstJsonBlock(text: string): string | null { * Parse JSON with fallback strategies per spec §9.8. * Returns parsed value on success, or null if all strategies fail. */ -export function resilientJsonParse(raw: string): Record | null { +export function resilientJsonParse( + raw: string, +): Record | null { // Strategy 1: Direct parse try { const result = JSON.parse(raw); - if (typeof result === "object" && result !== null) return result as Record; + if (typeof result === "object" && result !== null) + return result as Record; return { _raw: result }; } catch { // continue to fallbacks @@ -314,8 +323,11 @@ export function resilientJsonParse(raw: string): Record | null if (fenceMatch) { try { const result = JSON.parse(fenceMatch[1]); - console.warn("[prompty] Parsed tool arguments after stripping markdown fences"); - if (typeof result === "object" && result !== null) return result as Record; + console.warn( + "[prompty] Parsed tool arguments after stripping markdown fences", + ); + if (typeof result === "object" && result !== null) + return result as Record; return { _raw: result }; } catch { // continue @@ -327,8 +339,11 @@ export function resilientJsonParse(raw: string): Record | null if (block !== null) { try { const result = JSON.parse(block); - console.warn("[prompty] Parsed tool arguments after extracting JSON block"); - if (typeof result === "object" && result !== null) return result as Record; + console.warn( + "[prompty] Parsed tool arguments after extracting JSON block", + ); + if (typeof result === "object" && result !== null) + return result as Record; return { _raw: result }; } catch { // continue @@ -340,8 +355,11 @@ export function resilientJsonParse(raw: string): Record | null if (cleaned !== raw) { try { const result = JSON.parse(cleaned); - console.warn("[prompty] Parsed tool arguments after stripping trailing commas"); - if (typeof result === "object" && result !== null) return result as Record; + console.warn( + "[prompty] Parsed tool arguments after stripping trailing commas", + ); + if (typeof result === "object" && result !== null) + return result as Record; return { _raw: result }; } catch { // continue diff --git a/runtime/typescript/packages/core/src/core/types.ts b/runtime/typescript/packages/core/src/core/types.ts index 7b2a07351..de3875893 100644 --- a/runtime/typescript/packages/core/src/core/types.ts +++ b/runtime/typescript/packages/core/src/core/types.ts @@ -74,7 +74,11 @@ export class Message implements MessageHelpers { parts: ContentPart[]; metadata: Record; - constructor(init?: { role?: Role; parts?: ContentPart[]; metadata?: Record }) { + constructor(init?: { + role?: Role; + parts?: ContentPart[]; + metadata?: Record; + }) { this.role = init?.role ?? "user"; this.parts = init?.parts ?? []; this.metadata = init?.metadata ?? {}; @@ -109,7 +113,10 @@ function partToWireContent(part: ContentPart): Record { case "image": return { type: "image_url", - image_url: { url: part.source, ...(part.detail && { detail: part.detail }) }, + image_url: { + url: part.source, + ...(part.detail && { detail: part.detail }), + }, }; case "file": return { type: "file", file: { url: part.source } }; @@ -164,7 +171,13 @@ export interface ToolCall { export const RICH_KINDS = new Set(["thread", "image", "file", "audio"]); /** Standard message roles. */ -export const ROLES = new Set(["system", "user", "assistant", "developer", "tool"]); +export const ROLES = new Set([ + "system", + "user", + "assistant", + "developer", + "tool", +]); // --------------------------------------------------------------------------- // Streaming @@ -220,7 +233,11 @@ export function text(value: string): TextPart { } /** Create a Message with a single text part. */ -export function textMessage(role: Role, value: string, metadata: Record = {}): Message { +export function textMessage( + role: Role, + value: string, + metadata: Record = {}, +): Message { return new Message({ role, parts: [text(value)], metadata }); } diff --git a/runtime/typescript/packages/core/src/harness/adapters.ts b/runtime/typescript/packages/core/src/harness/adapters.ts index c00225b06..7413cfefa 100644 --- a/runtime/typescript/packages/core/src/harness/adapters.ts +++ b/runtime/typescript/packages/core/src/harness/adapters.ts @@ -20,13 +20,19 @@ import { } from "../model/index.js"; type JsonRecord = Record; -type ToolHandler = (args: JsonRecord, request: HostToolRequest) => unknown | Promise; +type ToolHandler = ( + args: JsonRecord, + request: HostToolRequest, +) => unknown | Promise; function checkpointKey(sessionId: string, checkpointId: string): string { return `${sessionId}\u0000${checkpointId}`; } -function requireCheckpointKey(checkpoint: Checkpoint): { sessionId: string; checkpointId: string } { +function requireCheckpointKey(checkpoint: Checkpoint): { + sessionId: string; + checkpointId: string; +} { if (!checkpoint.sessionId) { throw new Error("Checkpoint sessionId is required"); } @@ -101,12 +107,17 @@ export class InMemoryCheckpointStore implements CheckpointStore { return checkpoint; } - async load(sessionId: string, checkpointId: string): Promise { + async load( + sessionId: string, + checkpointId: string, + ): Promise { return this.checkpoints.get(checkpointKey(sessionId, checkpointId)) ?? null; } async listCheckpoints(sessionId: string): Promise { - return [...this.checkpoints.values()].filter((checkpoint) => checkpoint.sessionId === sessionId); + return [...this.checkpoints.values()].filter( + (checkpoint) => checkpoint.sessionId === sessionId, + ); } } @@ -150,7 +161,9 @@ export class FunctionHostToolExecutor implements HostToolExecutor { toolName: request.toolName, success: false, errorKind: "not_found", - result: { message: `No host tool registered for '${request.toolName}'` }, + result: { + message: `No host tool registered for '${request.toolName}'`, + }, durationMs: Date.now() - started, }); } diff --git a/runtime/typescript/packages/core/src/harness/replay-verifier.ts b/runtime/typescript/packages/core/src/harness/replay-verifier.ts index ee989f9ce..ce23fe0e3 100644 --- a/runtime/typescript/packages/core/src/harness/replay-verifier.ts +++ b/runtime/typescript/packages/core/src/harness/replay-verifier.ts @@ -9,7 +9,9 @@ import { export { ReplayVerificationRequest, ReplayVerificationResult }; -function comparable(record: ReplayJournalRecord | undefined): string | undefined { +function comparable( + record: ReplayJournalRecord | undefined, +): string | undefined { return record === undefined ? undefined : JSON.stringify(record.save()); } @@ -30,11 +32,12 @@ export class ReferenceReplayVerifier { index, expected: expectedRecord, actual: actualRecord, - message: expectedRecord === undefined - ? "Unexpected extra replay record" - : actualRecord === undefined - ? "Missing replay record" - : "Replay record mismatch", + message: + expectedRecord === undefined + ? "Unexpected extra replay record" + : actualRecord === undefined + ? "Missing replay record" + : "Replay record mismatch", }), ); } diff --git a/runtime/typescript/packages/core/src/harness/turn-runner.ts b/runtime/typescript/packages/core/src/harness/turn-runner.ts index 6ed69c2d0..270978628 100644 --- a/runtime/typescript/packages/core/src/harness/turn-runner.ts +++ b/runtime/typescript/packages/core/src/harness/turn-runner.ts @@ -24,7 +24,9 @@ type JsonRecord = Record; export { RunTurnRequest, RunTurnResult, TurnModelRequest, TurnModelResponse }; -export type TurnModelCallback = (request: TurnModelRequest) => TurnModelResponse | Promise; +export type TurnModelCallback = ( + request: TurnModelRequest, +) => TurnModelResponse | Promise; export interface TurnRunnerDependencies { eventSink: EventSink; @@ -68,18 +70,25 @@ export class ReferenceTurnRunner { attempt: 0, }); - const modelResponse = await this.dependencies.invokeModel(new TurnModelRequest({ - sessionId: request.sessionId, - turnId: request.turnId, - iteration, - inputs, - options, - toolResults: pendingToolResults, - })); + const modelResponse = await this.dependencies.invokeModel( + new TurnModelRequest({ + sessionId: request.sessionId, + turnId: request.turnId, + iteration, + inputs, + options, + toolResults: pendingToolResults, + }), + ); this.recordTurn("llm_complete", request.turnId, iteration, {}); - const checkpoint = await this.saveCheckpoint(request.sessionId, request.turnId, iteration, modelResponse); + const checkpoint = await this.saveCheckpoint( + request.sessionId, + request.turnId, + iteration, + modelResponse, + ); checkpoints.push(checkpoint); const toolRequests = modelResponse.toolRequests ?? []; @@ -90,7 +99,11 @@ export class ReferenceTurnRunner { pendingToolResults = []; for (const toolRequest of toolRequests) { - const toolResult = await this.resolveAndExecuteTool(request.turnId, iteration, toolRequest); + const toolResult = await this.resolveAndExecuteTool( + request.turnId, + iteration, + toolRequest, + ); pendingToolResults.push(toolResult); allToolResults.push(toolResult); } @@ -154,7 +167,9 @@ export class ReferenceTurnRunner { state: { iteration, output: modelResponse.output, - toolRequests: (modelResponse.toolRequests ?? []).map((toolRequest) => toolRequest.save()), + toolRequests: (modelResponse.toolRequests ?? []).map((toolRequest) => + toolRequest.save(), + ), ...(modelResponse.checkpointState ?? {}), }, createdAt: this.timestamp(), @@ -173,15 +188,23 @@ export class ReferenceTurnRunner { toolRequest: HostToolRequest, ): Promise { const permission = new PermissionRequest({ - requestId: toolRequest.requestId ? `${toolRequest.requestId}-permission` : this.id("permission"), + requestId: toolRequest.requestId + ? `${toolRequest.requestId}-permission` + : this.id("permission"), toolCallId: toolRequest.toolCallId, permission: "tool.execute", target: toolRequest.toolName, details: toolRequest.save(), }); - this.recordTurn("permission_requested", turnId, iteration, permission.save()); - const decision = await this.dependencies.permissionResolver.request(permission); + this.recordTurn( + "permission_requested", + turnId, + iteration, + permission.save(), + ); + const decision = + await this.dependencies.permissionResolver.request(permission); this.recordTurn("permission_completed", turnId, iteration, decision.save()); if (!decision.approved) { @@ -197,14 +220,30 @@ export class ReferenceTurnRunner { return deniedResult; } - this.recordTurn("tool_execution_start", turnId, iteration, toolRequest.save()); - const result = await this.dependencies.hostToolExecutor.execute(toolRequest); - this.recordTurn("tool_execution_complete", turnId, iteration, result.save()); + this.recordTurn( + "tool_execution_start", + turnId, + iteration, + toolRequest.save(), + ); + const result = + await this.dependencies.hostToolExecutor.execute(toolRequest); + this.recordTurn( + "tool_execution_complete", + turnId, + iteration, + result.save(), + ); this.recordTurn("tool_result", turnId, iteration, result.save()); return result; } - private recordTurn(type: TurnEvent["type"], turnId: string, iteration: number, payload: JsonRecord): void { + private recordTurn( + type: TurnEvent["type"], + turnId: string, + iteration: number, + payload: JsonRecord, + ): void { const event = new TurnEvent({ id: this.id("turn-event"), type, @@ -217,7 +256,12 @@ export class ReferenceTurnRunner { this.dependencies.journal.appendTurn(event); } - private recordSession(type: SessionEvent["type"], sessionId: string, turnId: string, payload: JsonRecord): void { + private recordSession( + type: SessionEvent["type"], + sessionId: string, + turnId: string, + payload: JsonRecord, + ): void { const event = new SessionEvent({ id: this.id("session-event"), type, diff --git a/runtime/typescript/packages/core/src/index.ts b/runtime/typescript/packages/core/src/index.ts index 3b8acf999..358e9fe5b 100644 --- a/runtime/typescript/packages/core/src/index.ts +++ b/runtime/typescript/packages/core/src/index.ts @@ -141,7 +141,8 @@ export { // Tracing // --------------------------------------------------------------------------- -export { Tracer, +export { + Tracer, trace, traceMethod, traceSpan, diff --git a/runtime/typescript/packages/core/src/parsers/prompty.ts b/runtime/typescript/packages/core/src/parsers/prompty.ts index 823d6da11..72f7de35f 100644 --- a/runtime/typescript/packages/core/src/parsers/prompty.ts +++ b/runtime/typescript/packages/core/src/parsers/prompty.ts @@ -13,11 +13,7 @@ import { resolve } from "node:path"; import { randomBytes } from "node:crypto"; import type { Agent } from "../model/agent.js"; -import { - type TextPart, - Message, - ROLES, -} from "../core/types.js"; +import { type TextPart, Message, ROLES } from "../core/types.js"; import type { Parser } from "../core/interfaces.js"; // Role boundary regex — matches lines like `system:` or `user[name="Alice"]:` @@ -30,7 +26,10 @@ import type { Parser } from "../core/interfaces.js"; // attribute-like start (`key=`) and then swallow the remainder of the bracket with a // single `[^\]]*` class that cannot overlap the closing `]`. This is structurally // linear. Individual attributes are still split later by ATTR_RE. See #446. -const ROLE_NAMES = [...ROLES].filter((r) => r !== "tool").sort().join("|"); +const ROLE_NAMES = [...ROLES] + .filter((r) => r !== "tool") + .sort() + .join("|"); const BOUNDARY_RE = new RegExp( `^\\s*#?\\s*(${ROLE_NAMES})(\\[\\w+\\s*=\\s*[^\\]]*\\])?\\s*:\\s*$`, "i", @@ -98,7 +97,15 @@ export class PromptyChatParser implements Parser { if (m) { if (contentBuffer.length > 0) { - messages.push(this.buildMessage(role, contentBuffer, attrs, hasBoundary ? nonce : undefined, basePath)); + messages.push( + this.buildMessage( + role, + contentBuffer, + attrs, + hasBoundary ? nonce : undefined, + basePath, + ), + ); contentBuffer = []; } @@ -114,7 +121,15 @@ export class PromptyChatParser implements Parser { // Flush remaining content if (contentBuffer.length > 0) { - messages.push(this.buildMessage(role, contentBuffer, attrs, hasBoundary ? nonce : undefined, basePath)); + messages.push( + this.buildMessage( + role, + contentBuffer, + attrs, + hasBoundary ? nonce : undefined, + basePath, + ), + ); } return messages; @@ -143,8 +158,8 @@ export class PromptyChatParser implements Parser { if (String(msgNonce ?? "") !== nonce) { throw new Error( "Nonce mismatch — possible prompt injection detected " + - "(strict mode is enabled). A template variable may be " + - "injecting role markers.", + "(strict mode is enabled). A template variable may be " + + "injecting role markers.", ); } } @@ -186,5 +201,4 @@ export class PromptyChatParser implements Parser { return result; } - } diff --git a/runtime/typescript/packages/core/src/renderers/index.ts b/runtime/typescript/packages/core/src/renderers/index.ts index 2d51c32bf..bdd38467f 100644 --- a/runtime/typescript/packages/core/src/renderers/index.ts +++ b/runtime/typescript/packages/core/src/renderers/index.ts @@ -1,3 +1,7 @@ export { NunjucksRenderer } from "./nunjucks.js"; export { MustacheRenderer } from "./mustache.js"; -export { prepareRenderInputs, getLastNonces, clearLastNonces } from "./common.js"; +export { + prepareRenderInputs, + getLastNonces, + clearLastNonces, +} from "./common.js"; diff --git a/runtime/typescript/packages/core/src/renderers/nunjucks.ts b/runtime/typescript/packages/core/src/renderers/nunjucks.ts index b0b776542..58397f44d 100644 --- a/runtime/typescript/packages/core/src/renderers/nunjucks.ts +++ b/runtime/typescript/packages/core/src/renderers/nunjucks.ts @@ -15,7 +15,12 @@ import { prepareRenderInputs } from "./common.js"; type NunjucksRuntime = { memberLookup: (object: unknown, property: unknown) => unknown; - callWrap: (callable: unknown, name: string, context: unknown, args: unknown[]) => unknown; + callWrap: ( + callable: unknown, + name: string, + context: unknown, + args: unknown[], + ) => unknown; }; const UNSAFE_PROPERTIES = new Set(["__proto__", "constructor", "prototype"]); @@ -39,15 +44,30 @@ function safeMemberLookup(object: unknown, property: unknown): unknown { } const descriptor = Object.getOwnPropertyDescriptor(object, property); - return descriptor !== undefined && "value" in descriptor ? descriptor.value : undefined; + return descriptor !== undefined && "value" in descriptor + ? descriptor.value + : undefined; } -function safeCallWrap(_callable: unknown, name: string, _context: unknown, _args: unknown[]): never { +function safeCallWrap( + _callable: unknown, + name: string, + _context: unknown, + _args: unknown[], +): never { throw new Error(`Template function calls are not allowed: ${name}`); } -function sanitizeValue(value: unknown, seen = new WeakMap()): unknown { - if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") { +function sanitizeValue( + value: unknown, + seen = new WeakMap(), +): unknown { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { return value; } @@ -71,7 +91,9 @@ function sanitizeValue(value: unknown, seen = new WeakMap()): u const result = Object.create(null) as Record; seen.set(value, result); - for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) { + for (const [key, descriptor] of Object.entries( + Object.getOwnPropertyDescriptors(value), + )) { if (!UNSAFE_PROPERTIES.has(key) && "value" in descriptor) { result[key] = sanitizeValue(descriptor.value, seen); } @@ -79,11 +101,16 @@ function sanitizeValue(value: unknown, seen = new WeakMap()): u return result; } -function sanitizeInputs(inputs: Record): Record { +function sanitizeInputs( + inputs: Record, +): Record { return sanitizeValue(inputs) as Record; } -function renderSafely(template: string, inputs: Record): string { +function renderSafely( + template: string, + inputs: Record, +): string { const runtime = nunjucks.runtime as unknown as NunjucksRuntime; const memberLookup = runtime.memberLookup; const callWrap = runtime.callWrap; diff --git a/runtime/typescript/packages/core/src/tracing/console.ts b/runtime/typescript/packages/core/src/tracing/console.ts index 303846ef7..ebbfe555c 100644 --- a/runtime/typescript/packages/core/src/tracing/console.ts +++ b/runtime/typescript/packages/core/src/tracing/console.ts @@ -20,8 +20,12 @@ export const consoleTracer: TracerFactory = (signature: string) => { console.error(`[Tracer] ── ${signature}`); return (key: string, value: unknown) => { if (key === "__end__") return; - const display = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value); - const truncated = display.length > 200 ? display.slice(0, 200) + "..." : display; + const display = + typeof value === "object" + ? JSON.stringify(value, null, 2) + : String(value); + const truncated = + display.length > 200 ? display.slice(0, 200) + "..." : display; console.error(`[Tracer] ${key}: ${truncated}`); }; }; diff --git a/runtime/typescript/packages/core/src/tracing/index.ts b/runtime/typescript/packages/core/src/tracing/index.ts index 92eecaf34..889f6ef9c 100644 --- a/runtime/typescript/packages/core/src/tracing/index.ts +++ b/runtime/typescript/packages/core/src/tracing/index.ts @@ -1,4 +1,11 @@ -export { Tracer, trace, traceMethod, traceSpan, sanitizeValue, toSerializable } from "./tracer.js"; +export { + Tracer, + trace, + traceMethod, + traceSpan, + sanitizeValue, + toSerializable, +} from "./tracer.js"; export type { TracerBackend, TracerFactory, SpanEmitter } from "./tracer.js"; export { consoleTracer } from "./console.js"; export { PromptyTracer, PKG_VERSION } from "./promptyTracer.js"; diff --git a/runtime/typescript/packages/core/src/tracing/otel.ts b/runtime/typescript/packages/core/src/tracing/otel.ts index 8e6e2f6b3..a4d7b21ed 100644 --- a/runtime/typescript/packages/core/src/tracing/otel.ts +++ b/runtime/typescript/packages/core/src/tracing/otel.ts @@ -76,7 +76,11 @@ function setAttributes( ): void { if (value === null || value === undefined) return; - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { span.setAttribute(prefix, value); return; } @@ -121,7 +125,10 @@ function setAttributes( * @param options - Optional configuration. * @returns A `TracerFactory` suitable for `Tracer.add()`. */ -export function otelTracer(api: OtelApi, options?: OtelTracerOptions): TracerFactory { +export function otelTracer( + api: OtelApi, + options?: OtelTracerOptions, +): TracerFactory { const tracerName = options?.tracerName ?? DEFAULT_TRACER_NAME; const tracer = api.trace.getTracer(tracerName); @@ -139,7 +146,10 @@ export function otelTracer(api: OtelApi, options?: OtelTracerOptions): TracerFac } if (key === "error") { - span.setStatus({ code: api.SpanStatusCode.ERROR, message: String(value) }); + span.setStatus({ + code: api.SpanStatusCode.ERROR, + message: String(value), + }); if (value instanceof Error) { span.recordException(value); if (value.stack) { diff --git a/runtime/typescript/packages/core/src/tracing/promptyTracer.ts b/runtime/typescript/packages/core/src/tracing/promptyTracer.ts index bfc96d207..8c3528ccb 100644 --- a/runtime/typescript/packages/core/src/tracing/promptyTracer.ts +++ b/runtime/typescript/packages/core/src/tracing/promptyTracer.ts @@ -23,7 +23,9 @@ function readPackageVersion(): string { for (let i = 0; i < 5; i++) { const candidate = path.join(dir, "package.json"); if (fs.existsSync(candidate)) { - const pkg = JSON.parse(fs.readFileSync(candidate, "utf-8")) as { version?: string }; + const pkg = JSON.parse(fs.readFileSync(candidate, "utf-8")) as { + version?: string; + }; if (pkg.version) return pkg.version; } dir = path.dirname(dir); @@ -150,10 +152,17 @@ export class PromptyTracer { }; // Hoist usage from result - if (frame.result && typeof frame.result === "object" && !Array.isArray(frame.result)) { + if ( + frame.result && + typeof frame.result === "object" && + !Array.isArray(frame.result) + ) { const result = frame.result as Record; if (result.usage && typeof result.usage === "object") { - frame.__usage = this.hoistUsage(result.usage as Record, frame.__usage ?? {}); + frame.__usage = this.hoistUsage( + result.usage as Record, + frame.__usage ?? {}, + ); } } @@ -163,7 +172,10 @@ export class PromptyTracer { if (item && typeof item === "object" && "usage" in item) { const r = item as Record; if (r.usage && typeof r.usage === "object") { - frame.__usage = this.hoistUsage(r.usage as Record, frame.__usage ?? {}); + frame.__usage = this.hoistUsage( + r.usage as Record, + frame.__usage ?? {}, + ); } } } @@ -196,7 +208,8 @@ export class PromptyTracer { cur: Record, ): Record { for (const [key, value] of Object.entries(src)) { - if (value === null || value === undefined || typeof value === "object") continue; + if (value === null || value === undefined || typeof value === "object") + continue; if (typeof value === "number") { cur[key] = (cur[key] ?? 0) + value; } diff --git a/runtime/typescript/packages/core/src/tracing/tracer.ts b/runtime/typescript/packages/core/src/tracing/tracer.ts index e7a021738..0fd807d7e 100644 --- a/runtime/typescript/packages/core/src/tracing/tracer.ts +++ b/runtime/typescript/packages/core/src/tracing/tracer.ts @@ -99,7 +99,10 @@ export function trace Promise>( ): T { const spanName = name ?? fn.name ?? "anonymous"; - const wrapped = async function (this: unknown, ...args: unknown[]): Promise { + const wrapped = async function ( + this: unknown, + ...args: unknown[] + ): Promise { const span = Tracer.start(spanName); const startTime = Date.now(); @@ -178,7 +181,10 @@ export function traceMethod(attributes?: Record) { ): PropertyDescriptor { const original = descriptor.value; - descriptor.value = async function (this: unknown, ...args: unknown[]): Promise { + descriptor.value = async function ( + this: unknown, + ...args: unknown[] + ): Promise { const span = Tracer.start(propertyKey); const startTime = Date.now(); @@ -250,9 +256,15 @@ export function sanitizeValue(key: string, value: unknown): unknown { */ export function toSerializable(obj: unknown): unknown { if (obj === null || obj === undefined) return obj; - if (typeof obj === "boolean" || typeof obj === "number" || typeof obj === "string") return obj; + if ( + typeof obj === "boolean" || + typeof obj === "number" || + typeof obj === "string" + ) + return obj; if (obj instanceof Date) return obj.toISOString(); - if (obj instanceof Error) return { name: obj.name, message: obj.message, stack: obj.stack }; + if (obj instanceof Error) + return { name: obj.name, message: obj.message, stack: obj.stack }; if (obj instanceof Map) return Object.fromEntries(obj); if (obj instanceof Set) return [...obj]; if (Array.isArray(obj)) return obj.map(toSerializable); diff --git a/runtime/typescript/packages/foundry/src/azure-executor.ts b/runtime/typescript/packages/foundry/src/azure-executor.ts index 51f227497..91fbb7884 100644 --- a/runtime/typescript/packages/foundry/src/azure-executor.ts +++ b/runtime/typescript/packages/foundry/src/azure-executor.ts @@ -6,10 +6,19 @@ import OpenAI, { AzureOpenAI } from "openai"; import type { Agent, Message } from "@prompty/core"; -import { ApiKeyConnection, ReferenceConnection, PromptyStream } from "@prompty/core"; +import { + ApiKeyConnection, + ReferenceConnection, + PromptyStream, +} from "@prompty/core"; import { getConnection, traceSpan, sanitizeValue } from "@prompty/core"; import { OpenAIExecutor } from "@prompty/openai"; -import { buildChatArgs, buildEmbeddingArgs, buildImageArgs, buildResponsesArgs } from "@prompty/openai"; +import { + buildChatArgs, + buildEmbeddingArgs, + buildImageArgs, + buildResponsesArgs, +} from "@prompty/openai"; export class AzureExecutor extends OpenAIExecutor { override async execute(agent: Agent, messages: Message[]): Promise { @@ -33,7 +42,13 @@ export class AzureExecutor extends OpenAIExecutor { }); const apiType = agent.model?.apiType ?? "chat"; - const result = await this.dispatchApiCall(client, clientName, agent, messages, apiType); + const result = await this.dispatchApiCall( + client, + clientName, + agent, + messages, + apiType, + ); emit("result", result); return result; }); @@ -54,10 +69,15 @@ export class AzureExecutor extends OpenAIExecutor { callEmit("signature", `${clientName}.chat.completions.create`); callEmit("inputs", sanitizeValue("create", args)); const result = await client.chat.completions.create( - args as unknown as Parameters[0], + args as unknown as Parameters< + typeof client.chat.completions.create + >[0], ); if (isStreaming) { - return new PromptyStream(`${clientName}Executor`, result as unknown as AsyncIterable); + return new PromptyStream( + `${clientName}Executor`, + result as unknown as AsyncIterable, + ); } callEmit("result", result); return result; @@ -97,7 +117,10 @@ export class AzureExecutor extends OpenAIExecutor { args as unknown as Parameters[0], ); if (isStreaming) { - return new PromptyStream(`${clientName}Executor`, result as unknown as AsyncIterable); + return new PromptyStream( + `${clientName}Executor`, + result as unknown as AsyncIterable, + ); } callEmit("result", result); return result; @@ -116,7 +139,9 @@ export class AzureExecutor extends OpenAIExecutor { } const kwargs = this.clientKwargs(agent); - return new AzureOpenAI(kwargs as ConstructorParameters[0]); + return new AzureOpenAI( + kwargs as ConstructorParameters[0], + ); } protected override clientKwargs(agent: Agent): Record { diff --git a/runtime/typescript/packages/foundry/src/azure-models.ts b/runtime/typescript/packages/foundry/src/azure-models.ts index d48f12558..31c5f9e6d 100644 --- a/runtime/typescript/packages/foundry/src/azure-models.ts +++ b/runtime/typescript/packages/foundry/src/azure-models.ts @@ -5,7 +5,13 @@ */ import { AzureOpenAI } from "openai"; -import { ModelInfo, ApiKeyConnection, FoundryConnection, ReferenceConnection, getConnection } from "@prompty/core"; +import { + ModelInfo, + ApiKeyConnection, + FoundryConnection, + ReferenceConnection, + getConnection, +} from "@prompty/core"; import type { Connection } from "@prompty/core"; interface FoundryDeployment { @@ -36,17 +42,23 @@ interface FoundryDeploymentClient { * Foundry project endpoints return deployments; Azure OpenAI resource endpoints return model catalog entries. * Both are mapped to `ModelInfo` so callers can present selectable model/deployment ids. */ -export async function listAzureModels(connection: Connection): Promise { +export async function listAzureModels( + connection: Connection, +): Promise { if (connection instanceof FoundryConnection) { if (!connection.endpoint) { - throw new Error("FoundryConnection requires a non-empty endpoint to list deployments."); + throw new Error( + "FoundryConnection requires a non-empty endpoint to list deployments.", + ); } const { DefaultAzureCredential } = await import("@azure/identity"); const credential = new DefaultAzureCredential(); return listFoundryDeployments(connection.endpoint, async () => { const token = await credential.getToken("https://ai.azure.com/.default"); if (!token?.token) { - throw new Error("DefaultAzureCredential did not return an access token."); + throw new Error( + "DefaultAzureCredential did not return an access token.", + ); } return token.token; }); @@ -55,7 +67,10 @@ export async function listAzureModels(connection: Connection): Promise { +async function listAzureOpenAIModels( + client: AzureOpenAI, +): Promise { const page = await client.models.list(); const models: ModelInfo[] = []; @@ -75,7 +92,10 @@ async function listAzureOpenAIModels(client: AzureOpenAI): Promise id: m.id, ownedBy: m.owned_by, // Azure may return maxContextLength in capabilities - contextWindow: typeof raw["maxContextLength"] === "number" ? raw["maxContextLength"] : undefined, + contextWindow: + typeof raw["maxContextLength"] === "number" + ? raw["maxContextLength"] + : undefined, }), ); } @@ -98,26 +118,45 @@ async function listFoundryDeployments( if (!response.ok) { const body = await response.text(); - throw new Error(`Failed to list Foundry deployments: ${response.status} ${response.statusText} — ${body.slice(0, 300)}`); + throw new Error( + `Failed to list Foundry deployments: ${response.status} ${response.statusText} — ${body.slice(0, 300)}`, + ); } const data = (await response.json()) as FoundryDeploymentsResponse; return (data.value ?? []).map((deployment) => { - const capabilities = deployment.properties?.capabilities ?? deployment.properties?.model?.capabilities; + const capabilities = + deployment.properties?.capabilities ?? + deployment.properties?.model?.capabilities; return new ModelInfo({ id: deployment.name, displayName: deployment.properties?.model?.name, ownedBy: deployment.properties?.model?.publisher ?? "azure", - contextWindow: getNumber(capabilities, ["maxContextLength", "contextWindow", "context_length"]) - ?? deployment.properties?.model?.maxContextLength, - inputModalities: getStringArray(capabilities, ["inputModalities", "input_modalities", "supportedInputModalities"]), - outputModalities: getStringArray(capabilities, ["outputModalities", "output_modalities", "supportedOutputModalities"]), + contextWindow: + getNumber(capabilities, [ + "maxContextLength", + "contextWindow", + "context_length", + ]) ?? deployment.properties?.model?.maxContextLength, + inputModalities: getStringArray(capabilities, [ + "inputModalities", + "input_modalities", + "supportedInputModalities", + ]), + outputModalities: getStringArray(capabilities, [ + "outputModalities", + "output_modalities", + "supportedOutputModalities", + ]), additionalProperties: deployment as unknown as Record, }); }); } -function getNumber(source: Record | undefined, keys: string[]): number | undefined { +function getNumber( + source: Record | undefined, + keys: string[], +): number | undefined { if (!source) return undefined; for (const key of keys) { const value = source[key]; @@ -130,23 +169,33 @@ function getNumber(source: Record | undefined, keys: string[]): return undefined; } -function getStringArray(source: Record | undefined, keys: string[]): string[] | undefined { +function getStringArray( + source: Record | undefined, + keys: string[], +): string[] | undefined { if (!source) return undefined; for (const key of keys) { const value = source[key]; if (Array.isArray(value)) return value.map((v) => String(v)); if (typeof value === "string" && value.trim()) { - return value.split(",").map((v) => v.trim()).filter(Boolean); + return value + .split(",") + .map((v) => v.trim()) + .filter(Boolean); } } return undefined; } -function isFoundryDeploymentClient(client: unknown): client is FoundryDeploymentClient { - return typeof client === "object" - && client !== null - && typeof (client as FoundryDeploymentClient).projectEndpoint === "string" - && typeof (client as FoundryDeploymentClient).getToken === "function"; +function isFoundryDeploymentClient( + client: unknown, +): client is FoundryDeploymentClient { + return ( + typeof client === "object" && + client !== null && + typeof (client as FoundryDeploymentClient).projectEndpoint === "string" && + typeof (client as FoundryDeploymentClient).getToken === "function" + ); } function buildAzureOpenAIClient(connection: Connection): AzureOpenAI { @@ -160,5 +209,7 @@ function buildAzureOpenAIClient(connection: Connection): AzureOpenAI { `Use 'key' for API key auth or 'reference' with registerConnection() for pre-configured clients.`, ); } - return new AzureOpenAI(kwargs as ConstructorParameters[0]); + return new AzureOpenAI( + kwargs as ConstructorParameters[0], + ); } diff --git a/runtime/typescript/packages/foundry/src/executor.ts b/runtime/typescript/packages/foundry/src/executor.ts index ca034342c..6a3de05fa 100644 --- a/runtime/typescript/packages/foundry/src/executor.ts +++ b/runtime/typescript/packages/foundry/src/executor.ts @@ -15,10 +15,19 @@ import OpenAI from "openai"; import { DefaultAzureCredential } from "@azure/identity"; import type { Agent, Message } from "@prompty/core"; -import { FoundryConnection, ReferenceConnection, PromptyStream } from "@prompty/core"; +import { + FoundryConnection, + ReferenceConnection, + PromptyStream, +} from "@prompty/core"; import { getConnection, traceSpan, sanitizeValue } from "@prompty/core"; import { OpenAIExecutor } from "@prompty/openai"; -import { buildChatArgs, buildEmbeddingArgs, buildImageArgs, buildResponsesArgs } from "@prompty/openai"; +import { + buildChatArgs, + buildEmbeddingArgs, + buildImageArgs, + buildResponsesArgs, +} from "@prompty/openai"; /** * Convert a Foundry project endpoint into the OpenAI/v1 base URL. @@ -55,17 +64,28 @@ export class FoundryExecutor extends OpenAIExecutor { if (conn instanceof ReferenceConnection) { ctorEmit("inputs", { source: "reference", name: conn.name }); } else if (conn instanceof FoundryConnection) { - ctorEmit("inputs", sanitizeValue("ctor", { - baseURL: conn.endpoint ? getOpenAIBaseURL(conn.endpoint) : undefined, - deployment: agent.model?.id, - auth: "DefaultAzureCredential", - })); + ctorEmit( + "inputs", + sanitizeValue("ctor", { + baseURL: conn.endpoint + ? getOpenAIBaseURL(conn.endpoint) + : undefined, + deployment: agent.model?.id, + auth: "DefaultAzureCredential", + }), + ); } ctorEmit("result", clientName); }); const apiType = agent.model?.apiType ?? "chat"; - const result = await this.dispatchApiCall(client, clientName, agent, messages, apiType); + const result = await this.dispatchApiCall( + client, + clientName, + agent, + messages, + apiType, + ); emit("result", result); return result; }); @@ -86,10 +106,15 @@ export class FoundryExecutor extends OpenAIExecutor { callEmit("signature", `${clientName}.chat.completions.create`); callEmit("inputs", sanitizeValue("create", args)); const result = await client.chat.completions.create( - args as unknown as Parameters[0], + args as unknown as Parameters< + typeof client.chat.completions.create + >[0], ); if (isStreaming) { - return new PromptyStream(`${clientName}Executor`, result as unknown as AsyncIterable); + return new PromptyStream( + `${clientName}Executor`, + result as unknown as AsyncIterable, + ); } callEmit("result", result); return result; @@ -129,7 +154,10 @@ export class FoundryExecutor extends OpenAIExecutor { args as unknown as Parameters[0], ); if (isStreaming) { - return new PromptyStream(`${clientName}Executor`, result as unknown as AsyncIterable); + return new PromptyStream( + `${clientName}Executor`, + result as unknown as AsyncIterable, + ); } callEmit("result", result); return result; @@ -153,7 +181,7 @@ export class FoundryExecutor extends OpenAIExecutor { if (!conn.endpoint) { throw new Error( "FoundryConnection requires a non-empty 'endpoint'. " + - "Set model.connection.endpoint to your Foundry project endpoint.", + "Set model.connection.endpoint to your Foundry project endpoint.", ); } const credential = new DefaultAzureCredential(); @@ -162,10 +190,15 @@ export class FoundryExecutor extends OpenAIExecutor { return new OpenAI({ baseURL, apiKey: "unused", - fetch: async (url: Parameters[0], init?: Parameters[1]): ReturnType => { + fetch: async ( + url: Parameters[0], + init?: Parameters[1], + ): ReturnType => { const token = await credential.getToken(FOUNDRY_TOKEN_SCOPE); if (!token?.token) { - throw new Error("DefaultAzureCredential did not return an access token."); + throw new Error( + "DefaultAzureCredential did not return an access token.", + ); } const headers = new Headers(init?.headers); headers.set("Authorization", `Bearer ${token.token}`); @@ -177,8 +210,8 @@ export class FoundryExecutor extends OpenAIExecutor { const kind = conn?.kind ?? "unknown"; throw new Error( `Connection kind '${kind}' is not supported by the Foundry executor. ` + - "Use 'foundry' (with endpoint + DefaultAzureCredential) or " + - "'reference' (with registerConnection()) for pre-configured clients.", + "Use 'foundry' (with endpoint + DefaultAzureCredential) or " + + "'reference' (with registerConnection()) for pre-configured clients.", ); } } diff --git a/runtime/typescript/packages/openai/src/executor.ts b/runtime/typescript/packages/openai/src/executor.ts index bdfa8761d..603a238ba 100644 --- a/runtime/typescript/packages/openai/src/executor.ts +++ b/runtime/typescript/packages/openai/src/executor.ts @@ -8,11 +8,22 @@ import OpenAI from "openai"; 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"; -import { buildChatArgs, buildEmbeddingArgs, buildImageArgs, buildResponsesArgs } from "./wire.js"; +import { + buildChatArgs, + buildEmbeddingArgs, + buildImageArgs, + buildResponsesArgs, +} from "./wire.js"; export class OpenAIExecutor implements Executor { async execute(agent: Agent, messages: Message[]): Promise { @@ -36,7 +47,13 @@ export class OpenAIExecutor 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; }); @@ -58,12 +75,17 @@ export class OpenAIExecutor implements Executor { callEmit("signature", `${clientName}.chat.completions.create`); callEmit("inputs", sanitizeValue("create", args)); const result = await client.chat.completions.create( - args as unknown as Parameters[0], + args as unknown as Parameters< + typeof client.chat.completions.create + >[0], ); if (isStreaming) { // Wrap streaming response for tracing — don't emit result yet, // PromptyStream will trace on exhaustion - return new PromptyStream(`${clientName}Executor`, result as unknown as AsyncIterable); + return new PromptyStream( + `${clientName}Executor`, + result as unknown as AsyncIterable, + ); } callEmit("result", result); return result; @@ -103,7 +125,10 @@ export class OpenAIExecutor implements Executor { args as unknown as Parameters[0], ); if (isStreaming) { - return new PromptyStream(`${clientName}Executor`, result as unknown as AsyncIterable); + return new PromptyStream( + `${clientName}Executor`, + result as unknown as AsyncIterable, + ); } callEmit("result", result); return result; @@ -124,28 +149,37 @@ export class OpenAIExecutor implements Executor { // Detect Responses API by checking for call_id on tool calls const isResponses = - toolCalls.length > 0 && "call_id" in (toolCalls[0] as Record); + toolCalls.length > 0 && + "call_id" in (toolCalls[0] as Record); if (isResponses) { // Responses API: individual function_call items for (const tc of toolCalls) { messages.push( - new Message({ role: "assistant", parts: [], metadata: { - responses_function_call: { - type: "function_call", - call_id: tc.id, - name: tc.name, - arguments: tc.arguments, + new Message({ + role: "assistant", + parts: [], + metadata: { + responses_function_call: { + type: "function_call", + call_id: tc.id, + name: tc.name, + arguments: tc.arguments, + }, }, - } }), + }), ); } for (let i = 0; i < toolCalls.length; i++) { messages.push( - new Message({ role: "tool", parts: [text(toolResults[i])], metadata: { - tool_call_id: toolCalls[i].id, - name: toolCalls[i].name, - } }), + new Message({ + role: "tool", + parts: [text(toolResults[i])], + metadata: { + tool_call_id: toolCalls[i].id, + name: toolCalls[i].name, + }, + }), ); } } else { @@ -156,16 +190,24 @@ export class OpenAIExecutor implements Executor { function: { name: tc.name, arguments: tc.arguments }, })); messages.push( - new Message({ role: "assistant", parts: textContent ? [text(textContent)] : [], metadata: { - tool_calls: rawToolCalls, - } }), + new Message({ + role: "assistant", + parts: textContent ? [text(textContent)] : [], + metadata: { + tool_calls: rawToolCalls, + }, + }), ); for (let i = 0; i < toolCalls.length; i++) { messages.push( - new Message({ role: "tool", parts: [text(toolResults[i])], metadata: { - tool_call_id: toolCalls[i].id, - name: toolCalls[i].name, - } }), + new Message({ + role: "tool", + parts: [text(toolResults[i])], + metadata: { + tool_call_id: toolCalls[i].id, + name: toolCalls[i].name, + }, + }), ); } } diff --git a/runtime/typescript/packages/openai/src/index.ts b/runtime/typescript/packages/openai/src/index.ts index fcaf5d61b..b94a9cb0c 100644 --- a/runtime/typescript/packages/openai/src/index.ts +++ b/runtime/typescript/packages/openai/src/index.ts @@ -8,7 +8,13 @@ export { OpenAIExecutor } from "./executor.js"; export { OpenAIProcessor, processResponse } from "./processor.js"; -export { messageToWire, buildChatArgs, buildEmbeddingArgs, buildImageArgs, buildResponsesArgs } from "./wire.js"; +export { + messageToWire, + buildChatArgs, + buildEmbeddingArgs, + buildImageArgs, + buildResponsesArgs, +} from "./wire.js"; export { listModels } from "./models.js"; // Auto-register on import diff --git a/runtime/typescript/packages/openai/src/models.ts b/runtime/typescript/packages/openai/src/models.ts index c42fb5eab..1d01a9360 100644 --- a/runtime/typescript/packages/openai/src/models.ts +++ b/runtime/typescript/packages/openai/src/models.ts @@ -5,18 +5,58 @@ */ import OpenAI from "openai"; -import { ModelInfo, ApiKeyConnection, ReferenceConnection, getConnection } from "@prompty/core"; +import { + ModelInfo, + ApiKeyConnection, + ReferenceConnection, + getConnection, +} from "@prompty/core"; import type { Connection } from "@prompty/core"; /** Known model metadata for enrichment (context windows and modalities). */ -const KNOWN_MODELS: Record = { - "gpt-4o": { contextWindow: 128_000, inputModalities: ["text", "image"], outputModalities: ["text"] }, - "gpt-4o-mini": { contextWindow: 128_000, inputModalities: ["text", "image"], outputModalities: ["text"] }, - "gpt-4-turbo": { contextWindow: 128_000, inputModalities: ["text", "image"], outputModalities: ["text"] }, - "gpt-4": { contextWindow: 8_192, inputModalities: ["text"], outputModalities: ["text"] }, - "gpt-3.5-turbo": { contextWindow: 16_385, inputModalities: ["text"], outputModalities: ["text"] }, - "text-embedding-3-small": { contextWindow: 8_191, inputModalities: ["text"], outputModalities: [] }, - "text-embedding-3-large": { contextWindow: 8_191, inputModalities: ["text"], outputModalities: [] }, +const KNOWN_MODELS: Record< + string, + { + contextWindow?: number; + inputModalities: string[]; + outputModalities: string[]; + } +> = { + "gpt-4o": { + contextWindow: 128_000, + inputModalities: ["text", "image"], + outputModalities: ["text"], + }, + "gpt-4o-mini": { + contextWindow: 128_000, + inputModalities: ["text", "image"], + outputModalities: ["text"], + }, + "gpt-4-turbo": { + contextWindow: 128_000, + inputModalities: ["text", "image"], + outputModalities: ["text"], + }, + "gpt-4": { + contextWindow: 8_192, + inputModalities: ["text"], + outputModalities: ["text"], + }, + "gpt-3.5-turbo": { + contextWindow: 16_385, + inputModalities: ["text"], + outputModalities: ["text"], + }, + "text-embedding-3-small": { + contextWindow: 8_191, + inputModalities: ["text"], + outputModalities: [], + }, + "text-embedding-3-large": { + contextWindow: 8_191, + inputModalities: ["text"], + outputModalities: [], + }, "dall-e-3": { inputModalities: ["text"], outputModalities: ["image"] }, }; diff --git a/runtime/typescript/packages/openai/src/processor.ts b/runtime/typescript/packages/openai/src/processor.ts index ef58ab95f..9ea9310a8 100644 --- a/runtime/typescript/packages/openai/src/processor.ts +++ b/runtime/typescript/packages/openai/src/processor.ts @@ -127,8 +127,7 @@ async function* streamGenerator( if (!choices || choices.length === 0) continue; const delta = (choices[0] as Record).delta as - | Record - | undefined; + Record | undefined; if (!delta) continue; // Content @@ -138,8 +137,7 @@ async function* streamGenerator( // Tool call deltas — accumulate index-keyed partial chunks const tcDeltas = delta.tool_calls as - | Record[] - | undefined; + Record[] | undefined; if (tcDeltas) { for (const tcDelta of tcDeltas) { const idx = tcDelta.index as number; diff --git a/runtime/typescript/packages/openai/src/wire.ts b/runtime/typescript/packages/openai/src/wire.ts index 6693d0747..e72c1df1b 100644 --- a/runtime/typescript/packages/openai/src/wire.ts +++ b/runtime/typescript/packages/openai/src/wire.ts @@ -120,9 +120,12 @@ export function buildEmbeddingArgs( if (Array.isArray(data)) { const texts = data.map((item: unknown) => { if (typeof item === "string") return item; - if (item && typeof item === "object" && "text" in item) return (item as { text: string }).text; + if (item && typeof item === "object" && "text" in item) + return (item as { text: string }).text; if (item && typeof item === "object" && "toTextContent" in item) { - const content = (item as { toTextContent: () => unknown }).toTextContent(); + const content = ( + item as { toTextContent: () => unknown } + ).toTextContent(); return typeof content === "string" ? content : String(content); } return String(item); @@ -162,16 +165,18 @@ export function buildImageArgs( } else if (Array.isArray(data)) { // Messages have .parts[].value for text content, or a .text getter prompt = data - .map((m: { text?: string; parts?: { kind: string; value: string }[] }) => { - if (typeof m.text === "string") return m.text; - if (Array.isArray(m.parts)) { - return m.parts - .filter((p) => p.kind === "text") - .map((p) => p.value) - .join(""); - } - return String(m); - }) + .map( + (m: { text?: string; parts?: { kind: string; value: string }[] }) => { + if (typeof m.text === "string") return m.text; + if (Array.isArray(m.parts)) { + return m.parts + .filter((p) => p.kind === "text") + .map((p) => p.value) + .join(""); + } + return String(m); + }, + ) .join("\n") .trim(); } else { @@ -207,7 +212,10 @@ function buildOptions(agent: Agent): Record { const opts = agent.model?.options; if (!opts) return {}; - const result = modelOptionsToWire(opts as unknown as Record, "openai"); + const result = modelOptionsToWire( + opts as unknown as Record, + "openai", + ); // Pass through additionalProperties — but don't overwrite mapped keys if (opts.additionalProperties) { @@ -222,14 +230,19 @@ function buildOptions(agent: Agent): Record { } function modelOptionsToWire( - opts: Record & { toWire?: (provider: string) => Record }, + opts: Record & { + toWire?: (provider: string) => Record; + }, provider: "openai" | "responses", ): Record { const result = typeof opts.toWire === "function" ? opts.toWire(provider) : {}; const mappings: Record>> = { frequencyPenalty: { openai: "frequency_penalty" }, - maxOutputTokens: { openai: "max_completion_tokens", responses: "max_output_tokens" }, + maxOutputTokens: { + openai: "max_completion_tokens", + responses: "max_output_tokens", + }, presencePenalty: { openai: "presence_penalty" }, seed: { openai: "seed" }, temperature: { openai: "temperature", responses: "temperature" }, @@ -243,7 +256,8 @@ function modelOptionsToWire( const target = mapping[provider]; const value = opts[key]; if (!target || 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; } @@ -255,11 +269,18 @@ function modelOptionsToWire( } /** Convert a Property list to a JSON Schema `{type: "object", properties: ...}`. */ -function schemaToWire(properties: unknown[], strict: boolean = false): Record { +function schemaToWire( + properties: unknown[], + strict: boolean = false, +): Record { const props: Record = {}; const required: string[] = []; - for (const p of properties as Array<{ name?: string; required?: boolean } & Parameters[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, strict && !p.required, strict); if (strict || p.required) required.push(p.name); @@ -271,22 +292,27 @@ function schemaToWire(properties: unknown[], strict: boolean = false): Record { +function propertyToJsonSchema( + prop: { + kind?: string; + required?: boolean; + description?: string; + enumValues?: unknown[]; + items?: unknown; + properties?: unknown[]; + nullable?: boolean; + oneOf?: unknown[]; + anyOf?: unknown[]; + }, + optional: boolean = false, + strict: boolean = false, +): Record { const jsonType = KIND_TO_JSON_TYPE[prop.kind ?? ""]; const schema: Record = 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; // Array items if (prop.kind === "array") { @@ -300,10 +326,12 @@ function propertyToJsonSchema(prop: { if (prop.properties) { const nested: Record = {}; 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, strict && !p.required, strict); - if (strict || p.required) req.push(p.name); + nested[p.name] = propertyToJsonSchema(p, strict && !p.required, strict); + if (strict || p.required) req.push(p.name); } schema.properties = nested; if (req.length > 0) schema.required = req; @@ -366,9 +394,15 @@ function toolsToWire(agent: Agent): Record[] { 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).name as string)); + params = params.filter( + (p) => + !boundNames.has((p as Record).name as string), + ); } - funcDef.parameters = schemaToWire(params, Boolean((t as { strict?: boolean }).strict)); + funcDef.parameters = schemaToWire( + params, + Boolean((t as { strict?: boolean }).strict), + ); } // Strict mode @@ -376,13 +410,17 @@ function toolsToWire(agent: Agent): Record[] { if (strict) { funcDef.strict = true; if (funcDef.parameters) { - (funcDef.parameters as Record).additionalProperties = false; + (funcDef.parameters as Record).additionalProperties = + false; } } result.push({ type: "function", function: funcDef }); } else if (t.kind === "prompty") { - const funcDef = projectPromptyTool(t as unknown as Record, agent); + const funcDef = projectPromptyTool( + t as unknown as Record, + agent, + ); result.push({ type: "function", function: funcDef }); } } @@ -396,14 +434,18 @@ function toolsToWire(agent: Agent): Record[] { * Loads the child `.prompty` file, uses its `inputs` as the * function parameters, and applies binding/strict stripping. */ -function projectPromptyTool(tool: Record, parent: Agent): Record { +function projectPromptyTool( + tool: Record, + parent: Agent, +): Record { const toolPath = tool.path as string | undefined; if (!toolPath) { throw new Error(`PromptyTool '${tool.name}' has no path`); } // Resolve child path relative to the parent .prompty file - const parentPath = (parent.metadata ?? {}).__source_path as string | undefined; + const parentPath = (parent.metadata ?? {}).__source_path as + string | undefined; if (!parentPath) { throw new Error( `Cannot resolve PromptyTool '${tool.name}': parent agent has no __source_path in metadata`, @@ -422,15 +464,21 @@ function projectPromptyTool(tool: Record, parent: Agent): Recor const childInputs = child.inputs ?? []; let params: unknown[] = childInputs; if (boundNames.size > 0) { - params = params.filter((p) => !boundNames.has((p as Record).name as string)); + params = params.filter( + (p) => !boundNames.has((p as Record).name as string), + ); } - funcDef.parameters = schemaToWire(params, Boolean((tool as { strict?: boolean }).strict)); + funcDef.parameters = schemaToWire( + params, + Boolean((tool as { strict?: boolean }).strict), + ); const strict = (tool as { strict?: boolean }).strict; if (strict) { funcDef.strict = true; if (funcDef.parameters) { - (funcDef.parameters as Record).additionalProperties = false; + (funcDef.parameters as Record).additionalProperties = + false; } } @@ -559,7 +607,10 @@ function buildResponsesOptions(agent: Agent): Record { const opts = agent.model?.options; if (!opts) return {}; - const result = modelOptionsToWire(opts as unknown as Record, "responses"); + const result = modelOptionsToWire( + opts as unknown as Record, + "responses", + ); // Pass through additionalProperties — but don't overwrite mapped keys if (opts.additionalProperties) { @@ -598,14 +649,18 @@ function responsesToolsToWire(agent: Agent): Record[] { if (strict) { tool.strict = true; if (tool.parameters) { - (tool.parameters as Record).additionalProperties = false; + (tool.parameters as Record).additionalProperties = + false; } } result.push(tool); } else if (t.kind === "prompty") { // Project prompty tool as a flat function definition (Responses API format) - const projected = projectPromptyTool(t as unknown as Record, agent); + const projected = projectPromptyTool( + t as unknown as Record, + agent, + ); const tool: Record = { type: "function", name: projected.name, @@ -617,7 +672,8 @@ function responsesToolsToWire(agent: Agent): Record[] { if (strict) { tool.strict = true; if (tool.parameters) { - (tool.parameters as Record).additionalProperties = false; + (tool.parameters as Record).additionalProperties = + false; } }