From 11086590ee7e8231c4df52e90067e82094e6af8c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 30 Aug 2026 11:27:24 +0000 Subject: [PATCH 1/6] feat(effect): add Effect bridge spike package Change-Id: I848f10cb6eb2d03e8f39c0ffb1aa813c6264a797 Signed-off-by: Thomas Kosiewski --- .github/workflows/pr-title.yml | 1 + README.md | 4 + packages/effect/LICENSE | 202 ++++++++ packages/effect/README.md | 186 +++++++ packages/effect/examples/01-generate.ts | 32 ++ packages/effect/examples/02-structured.ts | 41 ++ packages/effect/examples/03-sandbox.ts | 28 ++ packages/effect/package.json | 72 +++ packages/effect/src/errors.ts | 163 ++++++ packages/effect/src/index.ts | 22 + packages/effect/src/language-model.ts | 527 ++++++++++++++++++++ packages/effect/src/sandbox.ts | 209 ++++++++ packages/effect/src/schema.ts | 55 ++ packages/effect/test/errors.test.ts | 108 ++++ packages/effect/test/language-model.test.ts | 437 ++++++++++++++++ packages/effect/test/sandbox.test.ts | 232 +++++++++ packages/effect/test/schema.test.ts | 58 +++ packages/effect/tsconfig.json | 15 + packages/effect/tsup.config.ts | 11 + packages/effect/vitest.config.ts | 9 + pnpm-lock.yaml | 270 ++++++++++ pnpm-workspace.yaml | 6 + 22 files changed, 2688 insertions(+) create mode 100644 packages/effect/LICENSE create mode 100644 packages/effect/README.md create mode 100644 packages/effect/examples/01-generate.ts create mode 100644 packages/effect/examples/02-structured.ts create mode 100644 packages/effect/examples/03-sandbox.ts create mode 100644 packages/effect/package.json create mode 100644 packages/effect/src/errors.ts create mode 100644 packages/effect/src/index.ts create mode 100644 packages/effect/src/language-model.ts create mode 100644 packages/effect/src/sandbox.ts create mode 100644 packages/effect/src/schema.ts create mode 100644 packages/effect/test/errors.test.ts create mode 100644 packages/effect/test/language-model.test.ts create mode 100644 packages/effect/test/sandbox.test.ts create mode 100644 packages/effect/test/schema.test.ts create mode 100644 packages/effect/tsconfig.json create mode 100644 packages/effect/tsup.config.ts create mode 100644 packages/effect/vitest.config.ts diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index d7bf619..e14a788 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -42,6 +42,7 @@ jobs: sandbox agent provider + effect requireScope: false subjectPattern: ^(?![A-Z])(?!.*\.$).+$ subjectPatternError: | diff --git a/README.md b/README.md index f74b890..784e87b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ install instructions, usage, and API docs. | [`@coder/ai-sdk-agent`](./packages/agent) | [![npm](https://img.shields.io/npm/v/@coder/ai-sdk-agent.svg)](https://www.npmjs.com/package/@coder/ai-sdk-agent) | A Vercel AI SDK–compliant **`Agent`** (AI SDK v7) backed by **Coder Agents**, Coder's server-side agent runtime. `new CoderAgent()` returns a real `Agent` — `generate()`, `stream()`, tool calls, the whole interface. | | [`@coder/ai-sdk-provider`](./packages/provider) | [![npm](https://img.shields.io/npm/v/@coder/ai-sdk-provider.svg)](https://www.npmjs.com/package/@coder/ai-sdk-provider) | A **Vercel AI SDK provider** that routes `generateText` / `streamText` calls through your Coder deployment's [AI Gateway](https://coder.com/docs/ai-coder/ai-gateway). Point it at your deployment with a Coder API token and use any model it proxies — no raw provider keys, with per-user auth and audit. | +Experimental: [`@coder/ai-sdk-effect`](./packages/effect) bridges these +packages to [Effect](https://effect.website) (unpublished Phase 1 spike, see +[#144](https://github.com/coder/ai-sdk/issues/144)). + **Which package?** Need a **model** (text, streaming, or schema‑constrained structured output) through your deployment → `@coder/ai-sdk-provider`. Need Coder's **server‑side agent** (multi‑step tool loop, MCP, workspace file/shell tools) → diff --git a/packages/effect/LICENSE b/packages/effect/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/effect/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/effect/README.md b/packages/effect/README.md new file mode 100644 index 0000000..b5c0bad --- /dev/null +++ b/packages/effect/README.md @@ -0,0 +1,186 @@ +# @coder/ai-sdk-effect + +> [!WARNING] +> **Experimental spike (Phase 1 of +> [coder/ai-sdk#144](https://github.com/coder/ai-sdk/issues/144)).** This +> package is `private: true`, is **not published to npm**, and is excluded from +> release-please. Its API will change without notice until the spike review +> concludes. + +An [Effect](https://effect.website) bridge for the Coder AI SDK: exposes +`@coder/ai-sdk-provider` (Coder AI Gateway) and `@coder/ai-sdk-sandbox` +(workspace sandboxes) through Effect-native abstractions — an `@effect/ai` +`LanguageModel` service, typed `AiError` failures, Effect Schema interop with +the Vercel AI SDK, and scoped `Layer`s for workspace lifecycle. + +## Pinned surface + +Effect's AI packages move fast, so the spike pins exact versions and codes +against their concrete API shapes: + +| Dependency | Version | Surface used | +| ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------- | +| `effect` | `3.22.1` | `Effect`, `Layer`, `Stream`, `Schema`, `Context`, `Data` | +| `@effect/ai` | `0.37.0` | `LanguageModel.make` (`ProviderOptions` → encoded response parts), `AiError`, `Prompt`, `Response`, `Tool` | +| `@ai-sdk/provider` | `4.0.8` | `LanguageModelV4` spec types (same pin as `@coder/ai-sdk-provider`) | +| `@coder/ai-sdk-provider` | `0.4.4` | `createCoder`, `CoderProviderSettings` (published release, not `workspace:*` — see below) | +| `@coder/ai-sdk-sandbox` | `0.4.7` | `ensureCoderWorkspace`, `createCoderWorkspace`, `CoderTransport` | + +The spike depends on the _published_ `@coder/ai-sdk-*` releases rather than +`workspace:*` so that repo-wide `typecheck`/`test` need no cross-package build +ordering. Switching to `workspace:*` is part of the Phase 2 publishing +decision. + +## What exists (Phase 1) + +### `LanguageModel` over Coder AI Gateway + +`CoderLanguageModel.layer(modelId, source)` implements `@effect/ai`'s +`LanguageModel` service on top of the gateway. `source` is either +`CoderProviderSettings` or `{ provider }` for an existing `CoderProvider`, so +both auth modes (centralized and BYOK) work unchanged. `generateText`, +`generateObject`, and `streamText` are supported; structured outputs derive +their JSON schema from the Effect Schema you pass. + +```ts +import * as LanguageModel from "@effect/ai/LanguageModel"; +import * as Effect from "effect/Effect"; +import { CoderLanguageModel } from "@coder/ai-sdk-effect"; + +const program = Effect.gen(function* () { + const response = yield* LanguageModel.generateText({ + prompt: "In one sentence, what is Coder AI Gateway?", + }); + return response.text; +}); + +program.pipe( + Effect.provide( + CoderLanguageModel.layer("gpt-5.1", { + baseURL: "https://coder.example.com", + apiKey: process.env.CODER_SESSION_TOKEN, + }), + ), + Effect.runPromise, +); +``` + +The bridge core (`CoderLanguageModel.fromModel`) adapts any AI SDK +`LanguageModelV4`, which is also how the unit tests exercise it without HTTP. + +### Typed error taxonomy + +`@effect/ai`'s `AiError` is a **closed union**, so the bridge cannot add its +own error classes to the `LanguageModel` failure channel (a delta from the +tracking issue's sketch). Instead, failures are mapped losslessly into that +union (`HttpResponseError` keeps status, headers, and body) and `classifyError` +recovers the Coder-oriented taxonomy — `auth`, `rate-limit`, +`provider-unavailable`, `malformed-response`, `transport`, `unknown` — from +either an `AiError` or a raw AI SDK error. `isTransient` composes with +`Effect.retry`: + +```ts +import * as LanguageModel from "@effect/ai/LanguageModel"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import { isTransient } from "@coder/ai-sdk-effect"; + +const resilient = LanguageModel.generateText({ prompt: "hello" }).pipe( + Effect.retry({ + while: (error) => isTransient(error), + schedule: Schedule.exponential("250 millis"), + times: 3, + }), +); +``` + +### Effect Schema → Vercel AI SDK schemas + +AI SDK v7 accepts Standard Schema V1 values, but deriving the wire JSON schema +requires the optional `~standard.jsonSchema` converter, which +`Schema.standardSchemaV1` does not emit — passing a bare Effect standard +schema throws `Standard schema vendor 'effect' does not support JSON Schema +conversion`. `toAiSdkSchema` bridges the gap: the JSON schema is derived from +the Effect Schema (same derivation `@effect/ai` uses) and validation decodes +through it, so `tool()` inputs arrive fully decoded and typed: + +```ts +import * as Schema from "effect/Schema"; +import { tool } from "ai"; +import { toAiSdkSchema } from "@coder/ai-sdk-effect"; + +const getWeather = tool({ + description: "Look up the current weather", + inputSchema: toAiSdkSchema(Schema.Struct({ city: Schema.String })), + execute: async ({ city }) => ({ city, temperature: 21 }), +}); +``` + +### Scoped sandbox `Layer`s + +`acquireWorkspace` / `layerWorkspace` wrap `ensureCoderWorkspace` in +`Effect.acquireRelease`: the workspace is provisioned (get-or-create, start, +agent-readiness wait) when the scope opens and torn down when it closes — +including when the fiber is interrupted after acquisition. The teardown policy +(`delete-if-created` by default) never touches a workspace the acquisition +merely attached to. `acquireSession` / `layerSession` do the same for +`createCoderWorkspace(...).createSession()`. + +```ts +import * as Effect from "effect/Effect"; +import { CoderWorkspace, layerWorkspace } from "@coder/ai-sdk-effect"; + +const program = Effect.gen(function* () { + const workspace = yield* CoderWorkspace; + yield* Effect.log(`workspace ${workspace.name} ready`); +}); + +program.pipe( + Effect.provide( + layerWorkspace({ + workspace: "agent-sandbox", + create: { template: "docker" }, + }), + ), + Effect.runPromise, +); +``` + +## Spike caveats + +Honest limitations, chosen to keep the core small and correct. Unsupported +_inputs_ fail loudly with `MalformedInput`; response parts with no `@effect/ai` +equivalent are dropped: + +- **Provider-defined tools** and the **`oneOf` tool-choice mode** are not + expressible in `LanguageModelV4` call options → `MalformedInput`. +- Response parts of type `custom`, `reasoning-file`, and + `tool-approval-request`, and file payloads that are not raw data (URL / + provider-reference / inline-text), are dropped. +- `Prompt` provider options (per-part metadata) are not forwarded. +- Workspace acquisition is uninterruptible (standard `acquireRelease` + semantics); a slow `ensureCoderWorkspace` cannot be cancelled mid-flight. +- Telemetry: the `ProviderOptions.span` is not wired into request headers. + +## Phase 2 (not in this package yet) + +- `LanguageModel` over `CoderAgent`/chatd (`TurnTranslator` → `Effect.Stream`, + fiber interruption → `agent.interrupt()`). +- Retryable error tagging aligned with the agent package's + `CoderStreamError.isRetryable`. +- Publishing decision: versioning, `peerDependency` policy on + `effect`/`@effect/ai`, release-please wiring, `workspace:*` deps. + +## Examples + +Runnable against a real deployment (see each file's header): + +- [`examples/01-generate.ts`](./examples/01-generate.ts) — text generation. +- [`examples/02-structured.ts`](./examples/02-structured.ts) — structured + output via Effect Schema. +- [`examples/03-sandbox.ts`](./examples/03-sandbox.ts) — scoped workspace + acquisition. + +## License + +Apache-2.0 diff --git a/packages/effect/examples/01-generate.ts b/packages/effect/examples/01-generate.ts new file mode 100644 index 0000000..a6ca3e3 --- /dev/null +++ b/packages/effect/examples/01-generate.ts @@ -0,0 +1,32 @@ +/** + * Minimal text generation through the Effect `LanguageModel` bridge. + * + * Run with a real deployment: + * + * CODER_URL=https://coder.example.com CODER_SESSION_TOKEN=... \ + * CODER_MODEL=gpt-5.1 pnpm example:generate + */ +import * as LanguageModel from "@effect/ai/LanguageModel"; +import * as Effect from "effect/Effect"; +import { CoderLanguageModel } from "../src/index.js"; + +const modelId = process.env.CODER_MODEL ?? "gpt-5.1"; + +const program = Effect.gen(function* () { + const response = yield* LanguageModel.generateText({ + prompt: "In one sentence, what is Coder AI Gateway?", + }); + yield* Effect.log(response.text); + yield* Effect.log(`finishReason=${response.finishReason}`); + yield* Effect.log(`usage=${JSON.stringify(response.usage)}`); +}); + +program.pipe( + Effect.provide( + CoderLanguageModel.layer(modelId, { + baseURL: process.env.CODER_URL ?? "https://coder.example.com", + apiKey: process.env.CODER_SESSION_TOKEN, + }), + ), + Effect.runPromise, +); diff --git a/packages/effect/examples/02-structured.ts b/packages/effect/examples/02-structured.ts new file mode 100644 index 0000000..38558e9 --- /dev/null +++ b/packages/effect/examples/02-structured.ts @@ -0,0 +1,41 @@ +/** + * Structured output through the Effect `LanguageModel` bridge: the object is + * generated against a JSON schema derived from the Effect Schema and decoded + * back through it. + * + * Run with a real deployment: + * + * CODER_URL=https://coder.example.com CODER_SESSION_TOKEN=... \ + * CODER_MODEL=gpt-5.1 pnpm example:structured + */ +import * as LanguageModel from "@effect/ai/LanguageModel"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { CoderLanguageModel } from "../src/index.js"; + +const modelId = process.env.CODER_MODEL ?? "gpt-5.1"; + +const City = Schema.Struct({ + name: Schema.String, + country: Schema.String, + population: Schema.Number, +}); + +const program = Effect.gen(function* () { + const response = yield* LanguageModel.generateObject({ + prompt: "Describe the largest city in Germany.", + objectName: "city", + schema: City, + }); + yield* Effect.log(JSON.stringify(response.value)); +}); + +program.pipe( + Effect.provide( + CoderLanguageModel.layer(modelId, { + baseURL: process.env.CODER_URL ?? "https://coder.example.com", + apiKey: process.env.CODER_SESSION_TOKEN, + }), + ), + Effect.runPromise, +); diff --git a/packages/effect/examples/03-sandbox.ts b/packages/effect/examples/03-sandbox.ts new file mode 100644 index 0000000..fe39d32 --- /dev/null +++ b/packages/effect/examples/03-sandbox.ts @@ -0,0 +1,28 @@ +/** + * Scoped workspace acquisition: the workspace is provisioned on scope open and + * torn down (per policy) on scope close — including on interruption. + * + * Requires an ambient `coder` CLI login and a template to create from: + * + * CODER_TEMPLATE=docker pnpm example:sandbox + */ +import * as Effect from "effect/Effect"; +import { CoderWorkspace, layerWorkspace } from "../src/index.js"; + +const program = Effect.gen(function* () { + const workspace = yield* CoderWorkspace; + yield* Effect.log( + `workspace ${workspace.name} ready (created=${workspace.created}, id=${workspace.id})`, + ); +}); + +program.pipe( + Effect.provide( + layerWorkspace({ + workspace: "effect-bridge-example", + create: { template: process.env.CODER_TEMPLATE ?? "docker" }, + teardown: "delete-if-created", + }), + ), + Effect.runPromise, +); diff --git a/packages/effect/package.json b/packages/effect/package.json new file mode 100644 index 0000000..5f6c0fd --- /dev/null +++ b/packages/effect/package.json @@ -0,0 +1,72 @@ +{ + "name": "@coder/ai-sdk-effect", + "version": "0.0.0", + "private": true, + "description": "Experimental Effect bridge for the Coder AI SDK packages (Phase 1 spike, not published).", + "homepage": "https://github.com/coder/ai-sdk/tree/main/packages/effect#readme", + "bugs": { + "url": "https://github.com/coder/ai-sdk/issues" + }, + "license": "Apache-2.0", + "author": "Coder Technologies, Inc. (https://coder.com)", + "repository": { + "type": "git", + "url": "git+https://github.com/coder/ai-sdk.git", + "directory": "packages/effect" + }, + "files": [ + "dist", + "README.md" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "example:generate": "tsx examples/01-generate.ts", + "example:structured": "tsx examples/02-structured.ts", + "example:sandbox": "tsx examples/03-sandbox.ts" + }, + "dependencies": { + "@ai-sdk/provider": "4.0.8", + "@coder/ai-sdk-provider": "0.4.4", + "@coder/ai-sdk-sandbox": "0.4.7", + "@effect/ai": "0.37.0", + "effect": "3.22.1" + }, + "devDependencies": { + "@ai-sdk/harness": "^1.0.93", + "@ai-sdk/provider-utils": "^5.0.33", + "@effect/experimental": "0.61.1", + "@effect/platform": "0.97.1", + "@effect/rpc": "0.76.2", + "@types/node": "^26.4.0", + "@vitest/coverage-v8": "^4.1.11", + "ai": "^7.0.84", + "tsup": "^8.5.1", + "tsx": "^4.23.12", + "typescript": "^6.0.3", + "vitest": "^4.1.11", + "zod": "4.5.1" + }, + "peerDependencies": { + "ai": "^7.0.0" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/effect/src/errors.ts b/packages/effect/src/errors.ts new file mode 100644 index 0000000..fa89e0b --- /dev/null +++ b/packages/effect/src/errors.ts @@ -0,0 +1,163 @@ +/** + * Error mapping between the AI SDK's provider errors and `@effect/ai`'s + * `AiError` hierarchy. + * + * `@effect/ai`'s `AiError.AiError` is a *closed* union (`HttpRequestError | + * HttpResponseError | MalformedInput | MalformedOutput | UnknownError`), and + * `LanguageModel.make` implementations must fail with exactly those types. + * This module therefore maps AI SDK errors *into* that union without losing + * information (status code, headers, response body are preserved), and exposes + * {@link classifyError} / {@link classifyStatus} to recover the Coder-oriented + * failure taxonomy (auth, rate limit / quota, provider unavailable, malformed + * response, ...) from either side — useful with `Effect.retry` policies. + */ +import { + APICallError, + EmptyResponseBodyError, + InvalidArgumentError, + JSONParseError, + NoSuchModelError, + TypeValidationError, +} from "@ai-sdk/provider"; +import * as AiError from "@effect/ai/AiError"; +import * as Option from "effect/Option"; + +/** + * Coder-oriented classification of a gateway/model call failure. + * + * - `auth`: the credential was rejected (401/403) — Coder token or BYOK key. + * - `rate-limit`: the gateway or upstream throttled or exhausted quota (402/429). + * - `provider-unavailable`: the upstream or gateway failed server-side (5xx). + * - `malformed-response`: the response could not be parsed or validated. + * - `transport`: the request never produced an HTTP response (network error). + * - `unknown`: anything else. + */ +export type ErrorReason = + | "auth" + | "rate-limit" + | "provider-unavailable" + | "malformed-response" + | "transport" + | "unknown"; + +/** Classify an HTTP status code into an {@link ErrorReason}. */ +export const classifyStatus = (status: number): ErrorReason => { + if (status === 401 || status === 403) return "auth"; + if (status === 402 || status === 429) return "rate-limit"; + if (status >= 500) return "provider-unavailable"; + return "unknown"; +}; + +/** + * Classify an `@effect/ai` `AiError` into an {@link ErrorReason}. Raw AI SDK + * errors are classified by first mapping them with {@link toAiError}. + */ +export const classifyError = (error: AiError.AiError): ErrorReason => { + switch (error._tag) { + case "HttpResponseError": + return error.reason === "StatusCode" + ? classifyStatus(error.response.status) + : "malformed-response"; + case "HttpRequestError": + return "transport"; + case "MalformedOutput": + return "malformed-response"; + case "MalformedInput": + case "UnknownError": + return "unknown"; + } +}; + +/** + * Whether a failure is worth retrying (throttling, upstream outage, or a + * network error). Auth and malformed-response failures are terminal. + */ +export const isTransient = (error: AiError.AiError): boolean => { + const reason = classifyError(error); + return reason === "rate-limit" || reason === "provider-unavailable" || reason === "transport"; +}; + +const HTTP_METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE", "HEAD", "OPTIONS"] as const; +type HttpMethod = (typeof HTTP_METHODS)[number]; + +/** `AiError` request details require a literal method; default to POST. */ +const toHttpMethod = (method: string): HttpMethod => { + const upper = method.toUpperCase(); + const match = HTTP_METHODS.find((m) => m === upper); + return match ?? "POST"; +}; + +const requestDetails = (url: string, method: string = "POST") => { + const urlParams: Array = []; + const headers: Record = {}; + return { + method: toHttpMethod(method), + url, + urlParams, + hash: Option.none(), + headers, + }; +}; + +/** + * Map an arbitrary error thrown by an AI SDK model call into `@effect/ai`'s + * `AiError` union, preserving HTTP details where available. + */ +export const toAiError = (options: { + readonly module: string; + readonly method: string; + readonly error: unknown; +}): AiError.AiError => { + const { module, method, error } = options; + if (AiError.isAiError(error)) return error; + if (APICallError.isInstance(error)) { + if (error.statusCode === undefined) { + return new AiError.HttpRequestError({ + module, + method, + reason: "Transport", + request: requestDetails(error.url), + description: error.message, + cause: error, + }); + } + return new AiError.HttpResponseError({ + module, + method, + reason: "StatusCode", + request: requestDetails(error.url), + response: { + status: error.statusCode, + headers: error.responseHeaders ?? {}, + }, + body: error.responseBody, + description: `${error.message} (classified: ${classifyStatus(error.statusCode)})`, + }); + } + if ( + JSONParseError.isInstance(error) || + TypeValidationError.isInstance(error) || + EmptyResponseBodyError.isInstance(error) + ) { + return new AiError.MalformedOutput({ + module, + method, + description: error.message, + cause: error, + }); + } + if (NoSuchModelError.isInstance(error) || InvalidArgumentError.isInstance(error)) { + return new AiError.MalformedInput({ + module, + method, + description: error.message, + cause: error, + }); + } + return new AiError.UnknownError({ + module, + method, + description: error instanceof Error ? error.message : String(error), + cause: error, + }); +}; diff --git a/packages/effect/src/index.ts b/packages/effect/src/index.ts new file mode 100644 index 0000000..911460e --- /dev/null +++ b/packages/effect/src/index.ts @@ -0,0 +1,22 @@ +export { + classifyError, + classifyStatus, + type ErrorReason, + isTransient, + toAiError, +} from "./errors.js"; +export * as CoderLanguageModel from "./language-model.js"; +export { + acquireSession, + type AcquireSessionOptions, + acquireWorkspace, + type AcquireWorkspaceOptions, + CoderSandboxError, + CoderSession, + CoderWorkspace, + layerSession, + layerWorkspace, + type SessionTeardown, + type WorkspaceTeardown, +} from "./sandbox.js"; +export { toAiSdkSchema, toJsonSchema } from "./schema.js"; diff --git a/packages/effect/src/language-model.ts b/packages/effect/src/language-model.ts new file mode 100644 index 0000000..969ca2c --- /dev/null +++ b/packages/effect/src/language-model.ts @@ -0,0 +1,527 @@ +/** + * An `@effect/ai` `LanguageModel` implemented on top of Coder AI Gateway via + * `@coder/ai-sdk-provider`. + * + * The bridge core ({@link fromModel}) adapts *any* AI SDK + * `LanguageModelV4` to `@effect/ai`'s `LanguageModel` service; {@link make} / + * {@link layer} bind it to a Coder AI Gateway model resolved from + * `CoderProviderSettings` or an existing `CoderProvider`. + * + * Spike limitations (fail loudly with `MalformedInput` rather than silently + * degrade): provider-defined tools and the `oneOf` tool-choice mode are not + * expressible in the `LanguageModelV4` call options. Response parts with no + * `@effect/ai` equivalent (custom parts, reasoning files, tool approval + * requests, URL/reference/text file payloads) are dropped. + */ +import type { + JSONSchema7, + JSONValue, + LanguageModelV4, + LanguageModelV4CallOptions, + LanguageModelV4Content, + LanguageModelV4FilePart, + LanguageModelV4FinishReason, + LanguageModelV4Message, + LanguageModelV4Prompt, + LanguageModelV4StreamPart, + LanguageModelV4TextPart, + LanguageModelV4ToolChoice, + LanguageModelV4ToolResultOutput, + LanguageModelV4ToolResultPart, + LanguageModelV4Usage, + SharedV4FileData, +} from "@ai-sdk/provider"; +import { + type CoderProvider, + type CoderProviderSettings, + createCoder, +} from "@coder/ai-sdk-provider"; +import * as AiError from "@effect/ai/AiError"; +import * as LanguageModel from "@effect/ai/LanguageModel"; +import type * as Prompt from "@effect/ai/Prompt"; +import type * as Response from "@effect/ai/Response"; +import * as Tool from "@effect/ai/Tool"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { toAiError } from "./errors.js"; + +const MODULE = "CoderLanguageModel"; + +/** The model source: provider settings, or an already-constructed provider. */ +export type ProviderSource = CoderProviderSettings | { readonly provider: CoderProvider }; + +/** + * Build a `LanguageModel` service from any AI SDK `LanguageModelV4`. This is + * the bridge core; it performs no HTTP itself and is directly testable with a + * fake model. + */ +export const fromModel = (model: LanguageModelV4): Effect.Effect => + LanguageModel.make({ + generateText: (options) => generateText(model, options), + streamText: (options) => streamText(model, options), + }); + +/** {@link fromModel} as a `Layer`. */ +export const layerFromModel = (model: LanguageModelV4): Layer.Layer => + Layer.effect(LanguageModel.LanguageModel, fromModel(model)); + +/** + * Build a `LanguageModel` service for a Coder AI Gateway model. `source` is + * either `CoderProviderSettings` (a provider is constructed for you) or + * `{ provider }` to reuse an existing `CoderProvider` — both auth modes + * (centralized and BYOK) come along unchanged. + */ +export const make = ( + modelId: string, + source: ProviderSource, +): Effect.Effect => + Effect.flatMap( + Effect.try({ + try: () => { + const provider = "provider" in source ? source.provider : createCoder(source); + return provider.languageModel(modelId); + }, + catch: (error) => + new AiError.MalformedInput({ + module: MODULE, + method: "make", + description: error instanceof Error ? error.message : String(error), + cause: error, + }), + }), + fromModel, + ); + +/** {@link make} as a `Layer` providing `LanguageModel`. */ +export const layer = ( + modelId: string, + source: ProviderSource, +): Layer.Layer => + Layer.effect(LanguageModel.LanguageModel, make(modelId, source)); + +// --------------------------------------------------------------------------- +// generateText / streamText implementations +// --------------------------------------------------------------------------- + +const generateText = ( + model: LanguageModelV4, + options: LanguageModel.ProviderOptions, +): Effect.Effect, AiError.AiError> => + Effect.gen(function* () { + const callOptions = yield* buildCallOptions(options, "generateText"); + const result = yield* Effect.tryPromise({ + try: (signal) => model.doGenerate({ ...callOptions, abortSignal: signal }), + catch: (error) => toAiError({ module: MODULE, method: "generateText", error }), + }); + return yield* Effect.try({ + try: () => { + const parts: Array = []; + if (result.response !== undefined) { + parts.push(responseMetadataPart(result.response)); + } + for (const content of result.content) { + parts.push(...contentToParts(content)); + } + parts.push(finishPart(result.finishReason, result.usage)); + return parts; + }, + catch: (error) => toAiError({ module: MODULE, method: "generateText", error }), + }); + }); + +const streamText = ( + model: LanguageModelV4, + options: LanguageModel.ProviderOptions, +): Stream.Stream => + Stream.unwrapScoped( + Effect.gen(function* () { + const callOptions = yield* buildCallOptions(options, "streamText"); + // Tie request cancellation to the stream scope so that fiber + // interruption aborts the underlying HTTP request. Aborting after a + // normal end is a no-op. + const controller = yield* Effect.acquireRelease( + Effect.sync(() => new AbortController()), + (c) => Effect.sync(() => c.abort()), + ); + const result = yield* Effect.tryPromise({ + try: () => model.doStream({ ...callOptions, abortSignal: controller.signal }), + catch: (error) => toAiError({ module: MODULE, method: "streamText", error }), + }); + return Stream.fromReadableStream({ + evaluate: () => result.stream, + onError: (error) => toAiError({ module: MODULE, method: "streamText", error }), + }).pipe( + Stream.flatMap((part) => { + if (part.type === "error") { + return Stream.fail( + toAiError({ module: MODULE, method: "streamText", error: part.error }), + ); + } + try { + return Stream.fromIterable(streamPartToParts(part)); + } catch (error) { + return Stream.fail(toAiError({ module: MODULE, method: "streamText", error })); + } + }), + ); + }), + ); + +// --------------------------------------------------------------------------- +// Request mapping: @effect/ai ProviderOptions -> LanguageModelV4CallOptions +// --------------------------------------------------------------------------- + +const unsupported = (method: string, description: string): AiError.MalformedInput => + new AiError.MalformedInput({ module: MODULE, method, description }); + +const buildCallOptions = ( + options: LanguageModel.ProviderOptions, + method: string, +): Effect.Effect => + Effect.try({ + try: () => { + const callOptions: LanguageModelV4CallOptions = { + prompt: promptToV4(options.prompt), + }; + if (options.tools.length > 0) { + callOptions.tools = options.tools.map((tool) => toolToV4(tool, method)); + callOptions.toolChoice = toolChoiceToV4(options.toolChoice, method); + } + if (options.responseFormat.type === "json") { + callOptions.responseFormat = { + type: "json", + name: options.responseFormat.objectName, + // SAFETY: effect's JsonSchema7 output is structurally a JSON Schema + // draft-07 document; only the nominal type differs from `JSONSchema7`. + schema: Tool.getJsonSchemaFromSchemaAst(options.responseFormat.schema.ast) as JSONSchema7, + }; + } + return callOptions; + }, + catch: (error) => toAiError({ module: MODULE, method, error }), + }); + +const toolToV4 = (tool: Tool.Any, method: string) => { + if (Tool.isProviderDefined(tool)) { + throw unsupported( + method, + `provider-defined tool "${tool.name}" is not supported by the Coder AI Gateway bridge`, + ); + } + const result: NonNullable[number] = { + type: "function", + name: tool.name, + // SAFETY: effect's JsonSchema7 output is structurally a JSON Schema + // draft-07 document; only the nominal type differs from `JSONSchema7`. + inputSchema: Tool.getJsonSchemaFromSchemaAst(tool.parametersSchema.ast) as JSONSchema7, + }; + const description = + tool.description ?? Tool.getDescriptionFromSchemaAst(tool.parametersSchema.ast); + if (description !== undefined) { + result.description = description; + } + return result; +}; + +const toolChoiceToV4 = ( + toolChoice: LanguageModel.ProviderOptions["toolChoice"], + method: string, +): LanguageModelV4ToolChoice => { + if (toolChoice === "auto" || toolChoice === "none" || toolChoice === "required") { + return { type: toolChoice }; + } + if ("tool" in toolChoice) { + return { type: "tool", toolName: toolChoice.tool }; + } + throw unsupported(method, `the "oneOf" tool choice mode cannot be expressed in a gateway call`); +}; + +const promptToV4 = (prompt: Prompt.Prompt): LanguageModelV4Prompt => { + const messages: LanguageModelV4Prompt = []; + for (const message of prompt.content) { + messages.push(messageToV4(message)); + } + return messages; +}; + +const messageToV4 = (message: Prompt.Message): LanguageModelV4Message => { + switch (message.role) { + case "system": + return { role: "system", content: message.content }; + case "user": { + const content: Array = []; + for (const part of message.content) { + if (part.type === "text") { + content.push({ type: "text", text: part.text }); + } else { + content.push(filePartToV4(part)); + } + } + return { role: "user", content }; + } + case "assistant": { + const content: Extract["content"] = []; + for (const part of message.content) { + switch (part.type) { + case "text": + content.push({ type: "text", text: part.text }); + break; + case "reasoning": + content.push({ type: "reasoning", text: part.text }); + break; + case "file": + content.push(filePartToV4(part)); + break; + case "tool-call": + content.push({ + type: "tool-call", + toolCallId: part.id, + toolName: part.name, + input: part.params, + providerExecuted: part.providerExecuted, + }); + break; + case "tool-result": + content.push(toolResultToV4(part)); + break; + } + } + return { role: "assistant", content }; + } + case "tool": { + const content: Array = []; + for (const part of message.content) { + content.push(toolResultToV4(part)); + } + return { role: "tool", content }; + } + } +}; + +const filePartToV4 = (part: Prompt.FilePart): LanguageModelV4FilePart => { + let data: SharedV4FileData; + if (part.data instanceof URL) { + data = { type: "url", url: part.data }; + } else { + // Base64 string or raw bytes, per the `Prompt.FilePart` contract. + data = { type: "data", data: part.data }; + } + const result: LanguageModelV4FilePart = { + type: "file", + mediaType: part.mediaType, + data, + }; + if (part.fileName !== undefined) { + result.filename = part.fileName; + } + return result; +}; + +const toolResultToV4 = (part: Prompt.ToolResultPart): LanguageModelV4ToolResultPart => { + // SAFETY: tool results round-trip through JSON wire payloads (they are the + // decoded `result` of an executed tool call), so they are JSON-serializable. + const value = (part.result ?? null) as JSONValue; + let output: LanguageModelV4ToolResultOutput; + if (part.isFailure) { + output = { type: "error-json", value }; + } else { + output = { type: "json", value }; + } + return { + type: "tool-result", + toolCallId: part.id, + toolName: part.name, + output, + }; +}; + +// --------------------------------------------------------------------------- +// Response mapping: V4 content/stream parts -> @effect/ai encoded parts +// --------------------------------------------------------------------------- + +/** Per the V4 spec, tool call arguments arrive as a stringified JSON object. */ +const toolCallPart = (content: { + readonly toolCallId: string; + readonly toolName: string; + readonly input: string; + readonly providerExecuted?: boolean; +}): Response.ToolCallPartEncoded => { + let params: Response.ToolCallPartEncoded["params"]; + try { + params = content.input.trim() === "" ? {} : JSON.parse(content.input); + } catch (cause) { + throw new AiError.MalformedOutput({ + module: MODULE, + method: "toolCallPart", + description: `tool call arguments are not valid JSON: ${content.input}`, + cause, + }); + } + return { + type: "tool-call", + id: content.toolCallId, + name: content.toolName, + params, + providerExecuted: content.providerExecuted ?? false, + }; +}; + +/** + * `Response.FilePartEncoded.data` is a base64 string; only `data` payloads can + * be represented. URL / reference / text payloads are dropped (spike caveat). + */ +const fileDataToBase64 = (data: SharedV4FileData): string | undefined => { + if (data.type !== "data") return undefined; + if (data.data instanceof Uint8Array) { + return Buffer.from(data.data).toString("base64"); + } + return data.data; +}; + +/** Content types that map identically in generate results and stream parts. */ +type SharedContent = Exclude; + +type SharedPartEncoded = + | Response.ToolCallPartEncoded + | Response.ToolResultPartEncoded + | Response.FilePartEncoded + | Response.UrlSourcePartEncoded + | Response.DocumentSourcePartEncoded; + +const contentToParts = (content: LanguageModelV4Content): Array => { + switch (content.type) { + case "text": + return [{ type: "text", text: content.text }]; + case "reasoning": + return [{ type: "reasoning", text: content.text }]; + default: + return sharedContentToParts(content); + } +}; + +const sharedContentToParts = (content: SharedContent): Array => { + switch (content.type) { + case "tool-call": + return [toolCallPart(content)]; + case "tool-result": + return [ + { + type: "tool-result", + id: content.toolCallId, + name: content.toolName, + result: content.result, + isFailure: content.isError ?? false, + providerExecuted: true, + }, + ]; + case "file": { + const data = fileDataToBase64(content.data); + if (data === undefined) return []; + return [{ type: "file", mediaType: content.mediaType, data }]; + } + case "source": + if (content.sourceType === "url") { + return [ + { + type: "source", + sourceType: "url", + id: content.id, + url: content.url, + title: content.title ?? content.url, + }, + ]; + } + return [ + { + type: "source", + sourceType: "document", + id: content.id, + mediaType: content.mediaType, + title: content.title, + fileName: content.filename, + }, + ]; + // No @effect/ai representation exists for these; see module docs. + case "custom": + case "reasoning-file": + case "tool-approval-request": + return []; + } +}; + +const streamPartToParts = ( + part: Exclude, +): Array => { + switch (part.type) { + case "text-start": + return [{ type: "text-start", id: part.id }]; + case "text-delta": + return [{ type: "text-delta", id: part.id, delta: part.delta }]; + case "text-end": + return [{ type: "text-end", id: part.id }]; + case "reasoning-start": + return [{ type: "reasoning-start", id: part.id }]; + case "reasoning-delta": + return [{ type: "reasoning-delta", id: part.id, delta: part.delta }]; + case "reasoning-end": + return [{ type: "reasoning-end", id: part.id }]; + case "tool-input-start": + return [ + { + type: "tool-params-start", + id: part.id, + name: part.toolName, + providerExecuted: part.providerExecuted ?? false, + }, + ]; + case "tool-input-delta": + return [{ type: "tool-params-delta", id: part.id, delta: part.delta }]; + case "tool-input-end": + return [{ type: "tool-params-end", id: part.id }]; + case "response-metadata": + return [responseMetadataPart(part)]; + case "finish": + return [finishPart(part.finishReason, part.usage)]; + // `stream-start` (warnings) and `raw` chunks have no equivalent. + case "stream-start": + case "raw": + return []; + default: + return sharedContentToParts(part); + } +}; + +const responseMetadataPart = (metadata: { + readonly id?: string; + readonly modelId?: string; + readonly timestamp?: Date; +}): Response.ResponseMetadataPartEncoded => ({ + type: "response-metadata", + id: metadata.id, + modelId: metadata.modelId, + timestamp: metadata.timestamp?.toISOString(), +}); + +const finishPart = ( + finishReason: LanguageModelV4FinishReason, + usage: LanguageModelV4Usage, +): Response.FinishPartEncoded => { + const inputTokens = usage.inputTokens.total; + const outputTokens = usage.outputTokens.total; + let totalTokens: number | undefined; + if (inputTokens !== undefined && outputTokens !== undefined) { + totalTokens = inputTokens + outputTokens; + } + return { + type: "finish", + reason: finishReason.unified, + usage: { + inputTokens, + outputTokens, + totalTokens, + reasoningTokens: usage.outputTokens.reasoning, + cachedInputTokens: usage.inputTokens.cacheRead, + }, + }; +}; diff --git a/packages/effect/src/sandbox.ts b/packages/effect/src/sandbox.ts new file mode 100644 index 0000000..22e2fe2 --- /dev/null +++ b/packages/effect/src/sandbox.ts @@ -0,0 +1,209 @@ +/** + * Scoped resource management for Coder workspace sandboxes. + * + * Wraps `@coder/ai-sdk-sandbox`'s imperative acquisition APIs in + * `Effect.acquireRelease`, so workspace provisioning (get-or-create, start, + * agent-readiness waits) and teardown compose with Effect scopes: the release + * step runs when the scope closes, both on success and when the owning fiber + * is interrupted after acquisition. Acquisition itself is uninterruptible, per + * standard `acquireRelease` semantics. + * + * Transports stay out of scope here (spike): callers can pass any + * `CoderTransport`; the default is the ambient-login CLI transport. + */ +import { + CoderCliTransport, + type CoderTransport, + type CoderWorkspaceSession, + type CoderWorkspaceSettings, + createCoderWorkspace, + ensureCoderWorkspace, + type EnsureCoderWorkspaceSettings, + type EnsuredCoderWorkspace, +} from "@coder/ai-sdk-sandbox"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; + +/** Failure while acquiring or releasing a Coder workspace sandbox resource. */ +export class CoderSandboxError extends Data.TaggedError("CoderSandboxError")<{ + readonly phase: "acquire" | "release"; + readonly description: string; + readonly cause: unknown; +}> { + override get message(): string { + return `${this.phase}: ${this.description}`; + } +} + +// --------------------------------------------------------------------------- +// Workspace (ensureCoderWorkspace) +// --------------------------------------------------------------------------- + +/** A ready Coder workspace acquired for the lifetime of the current scope. */ +export class CoderWorkspace extends Context.Tag("@coder/ai-sdk-effect/CoderWorkspace")< + CoderWorkspace, + EnsuredCoderWorkspace +>() {} + +/** + * What to do with the workspace when the scope closes: + * + * - `delete-if-created` (default): delete the workspace, but only when this + * acquisition actually created it. A pre-existing workspace is never touched. + * - `stop-if-created`: stop instead of delete, same ownership rule. + * - `keep`: never touch the workspace on release. + */ +export type WorkspaceTeardown = "delete-if-created" | "stop-if-created" | "keep"; + +/** Options for {@link acquireWorkspace}: provisioning settings + teardown policy. */ +export interface AcquireWorkspaceOptions extends Omit { + readonly teardown?: WorkspaceTeardown; +} + +/** + * Acquire a ready Coder workspace (`ensureCoderWorkspace`) as a scoped + * resource. Teardown follows {@link WorkspaceTeardown} and runs on scope + * close — including when the fiber is interrupted after acquisition. A release + * failure is a defect (the workspace may leak; we crash loudly rather than + * swallow it). + */ +export const acquireWorkspace = ( + options: AcquireWorkspaceOptions, +): Effect.Effect => { + const { teardown = "delete-if-created", ...settings } = options; + return Effect.map( + Effect.acquireRelease( + Effect.tryPromise({ + try: async () => { + // The same transport instance must perform acquisition and teardown. + const transport = settings.transport ?? new CoderCliTransport(); + const workspace = await ensureCoderWorkspace({ ...settings, transport }); + return { workspace, transport }; + }, + catch: (cause) => + new CoderSandboxError({ + phase: "acquire", + description: `ensureCoderWorkspace failed for "${options.workspace}"`, + cause, + }), + }), + ({ transport, workspace }) => + releaseWorkspace(transport, workspace, options.workspace, teardown), + ), + ({ workspace }) => workspace, + ); +}; + +const releaseWorkspace = ( + transport: CoderTransport, + workspace: EnsuredCoderWorkspace, + name: string, + teardown: WorkspaceTeardown, +): Effect.Effect => { + if (teardown === "keep" || !workspace.created) return Effect.void; + return Effect.orDie( + Effect.tryPromise({ + try: async () => { + if (teardown === "delete-if-created") { + await transport.destroy(name); + } else { + await transport.stop(name); + } + }, + catch: (cause) => + new CoderSandboxError({ + phase: "release", + description: `failed to ${teardown === "delete-if-created" ? "delete" : "stop"} workspace "${name}"`, + cause, + }), + }), + ); +}; + +/** {@link acquireWorkspace} as a scoped `Layer` providing {@link CoderWorkspace}. */ +export const layerWorkspace = ( + options: AcquireWorkspaceOptions, +): Layer.Layer => + Layer.scoped(CoderWorkspace, acquireWorkspace(options)); + +// --------------------------------------------------------------------------- +// Session (createCoderWorkspace + createSession) +// --------------------------------------------------------------------------- + +/** A live Coder workspace session acquired for the lifetime of the current scope. */ +export class CoderSession extends Context.Tag("@coder/ai-sdk-effect/CoderSession")< + CoderSession, + CoderWorkspaceSession +>() {} + +/** + * What to do with the session when the scope closes. Both variants always + * release host-side resources (port forwards); whether the workspace itself is + * stopped/deleted is governed by the sandbox package's `ownsLifecycle` rules. + */ +export type SessionTeardown = "stop" | "destroy"; + +/** Options for {@link acquireSession}. */ +export interface AcquireSessionOptions { + /** Settings passed through to `createCoderWorkspace`. */ + readonly settings: CoderWorkspaceSettings; + /** Optional session id used to derive per-session workspace names. */ + readonly sessionId?: string; + /** Teardown behavior on scope close. Default: `"stop"`. */ + readonly teardown?: SessionTeardown; +} + +/** + * Acquire a Coder workspace session as a scoped resource. Release calls + * `session.stop()` (default) or `session.destroy()` on scope close, including + * on interruption after acquisition. + */ +export const acquireSession = ( + options: AcquireSessionOptions, +): Effect.Effect => { + const teardown = options.teardown ?? "stop"; + return Effect.acquireRelease( + Effect.tryPromise({ + try: async () => { + const provider = createCoderWorkspace(options.settings); + const session = await provider.createSession({ sessionId: options.sessionId }); + // SAFETY: createCoderWorkspace always constructs CoderWorkspaceSession + // instances; the harness provider interface just types them loosely. + return session as CoderWorkspaceSession; + }, + catch: (cause) => + new CoderSandboxError({ + phase: "acquire", + description: "createSession failed", + cause, + }), + }), + (session) => + Effect.orDie( + Effect.tryPromise({ + try: async () => { + if (teardown === "destroy") { + await session.destroy(); + } else { + await session.stop(); + } + }, + catch: (cause) => + new CoderSandboxError({ + phase: "release", + description: `failed to ${teardown} session "${session.id}"`, + cause, + }), + }), + ), + ); +}; + +/** {@link acquireSession} as a scoped `Layer` providing {@link CoderSession}. */ +export const layerSession = ( + options: AcquireSessionOptions, +): Layer.Layer => + Layer.scoped(CoderSession, acquireSession(options)); diff --git a/packages/effect/src/schema.ts b/packages/effect/src/schema.ts new file mode 100644 index 0000000..0ebce44 --- /dev/null +++ b/packages/effect/src/schema.ts @@ -0,0 +1,55 @@ +/** + * Effect Schema interop with the Vercel AI SDK's tool-definition and + * structured-output surfaces. + * + * Why this exists: AI SDK v7's `tool()` / `generateObject()` accept Standard + * Schema V1 values, but deriving the *wire* JSON schema from a standard schema + * requires the optional `~standard.jsonSchema` converter — which + * `Schema.standardSchemaV1` from `effect` does not emit. Passing a bare Effect + * standard schema therefore throws `Standard schema vendor 'effect' does not + * support JSON Schema conversion` inside the AI SDK. {@link toAiSdkSchema} + * bridges the gap by pairing the Effect-derived JSON schema with an + * Effect-Schema-backed validator in the AI SDK's own `Schema` container, which + * every `FlexibleSchema` surface accepts. + */ +import type { JSONSchema7 } from "@ai-sdk/provider"; +import * as Tool from "@effect/ai/Tool"; +import * as Either from "effect/Either"; +import * as ParseResult from "effect/ParseResult"; +import * as Schema from "effect/Schema"; +import { jsonSchema, type Schema as AiSdkSchema } from "ai"; + +/** + * Derive the wire JSON schema (draft-07) for an Effect Schema, using the same + * derivation `@effect/ai` uses for its own tool definitions. + */ +export const toJsonSchema = (schema: Schema.Schema.Any): JSONSchema7 => + // SAFETY: effect's JsonSchema7 output is structurally a JSON Schema draft-07 + // document; only the nominal type differs from `JSONSchema7`. + Tool.getJsonSchemaFromSchemaAst(schema.ast) as JSONSchema7; + +/** + * Bridge an Effect Schema to an AI SDK schema usable anywhere the AI SDK + * accepts a `FlexibleSchema`: `tool({ inputSchema })`, `generateObject`, + * `streamObject`, ... Validation decodes through the Effect Schema, so values + * are fully decoded (defaults, transformations) and typed as `A` on the way + * out — wire types in both directions stay consistent with the JSON schema. + * + * The schema must be synchronously decodable and context-free (`R = never`); + * async or effectful schemas fail validation with a descriptive error. + */ +export const toAiSdkSchema = (schema: Schema.Schema): AiSdkSchema => { + const decode = Schema.decodeUnknownEither(schema); + return jsonSchema(toJsonSchema(schema), { + validate: (value) => { + const result = decode(value); + if (Either.isRight(result)) { + return { success: true, value: result.right }; + } + return { + success: false, + error: new Error(ParseResult.TreeFormatter.formatErrorSync(result.left)), + }; + }, + }); +}; diff --git a/packages/effect/test/errors.test.ts b/packages/effect/test/errors.test.ts new file mode 100644 index 0000000..83c706d --- /dev/null +++ b/packages/effect/test/errors.test.ts @@ -0,0 +1,108 @@ +import { APICallError, JSONParseError, TypeValidationError } from "@ai-sdk/provider"; +import * as AiError from "@effect/ai/AiError"; +import { describe, expect, it } from "vitest"; +import { classifyError, classifyStatus, isTransient, toAiError } from "../src/errors.js"; + +const apiCallError = (statusCode?: number): APICallError => + new APICallError({ + message: `gateway failure${statusCode === undefined ? "" : ` (${statusCode})`}`, + url: "https://coder.example.com/api/v2/aibridge/openai/v1/chat/completions", + requestBodyValues: {}, + statusCode, + responseHeaders: { "x-request-id": "req_123" }, + responseBody: statusCode === undefined ? undefined : `{"error":"failure"}`, + }); + +describe("classifyStatus", () => { + it.each([ + [401, "auth"], + [403, "auth"], + [402, "rate-limit"], + [429, "rate-limit"], + [500, "provider-unavailable"], + [502, "provider-unavailable"], + [529, "provider-unavailable"], + [404, "unknown"], + [400, "unknown"], + ] as const)("classifies %d as %s", (status, expected) => { + expect(classifyStatus(status)).toBe(expected); + }); +}); + +describe("toAiError", () => { + const map = (error: unknown): AiError.AiError => + toAiError({ module: "Test", method: "call", error }); + + it("maps an HTTP status failure to HttpResponseError, preserving details", () => { + const error = map(apiCallError(429)); + expect(error._tag).toBe("HttpResponseError"); + const response = error as AiError.HttpResponseError; + expect(response.response.status).toBe(429); + expect(response.response.headers).toEqual({ "x-request-id": "req_123" }); + expect(response.body).toBe(`{"error":"failure"}`); + expect(response.description).toContain("classified: rate-limit"); + expect(response.module).toBe("Test"); + expect(response.method).toBe("call"); + }); + + it("maps a network failure (no status) to HttpRequestError", () => { + const error = map(apiCallError(undefined)); + expect(error._tag).toBe("HttpRequestError"); + expect((error as AiError.HttpRequestError).reason).toBe("Transport"); + }); + + it("maps parse/validation failures to MalformedOutput", () => { + const parse = map(new JSONParseError({ text: "not json", cause: new Error("bad") })); + expect(parse._tag).toBe("MalformedOutput"); + const validation = map(new TypeValidationError({ value: 1, cause: new Error("bad") })); + expect(validation._tag).toBe("MalformedOutput"); + }); + + it("passes an existing AiError through unchanged", () => { + const original = new AiError.MalformedInput({ module: "Test", method: "call" }); + expect(map(original)).toBe(original); + }); + + it("maps anything else to UnknownError with the cause attached", () => { + const cause = new Error("boom"); + const error = map(cause); + expect(error._tag).toBe("UnknownError"); + expect((error as AiError.UnknownError).cause).toBe(cause); + }); +}); + +describe("classifyError / isTransient", () => { + it("classifies bridge-produced AiErrors back to the taxonomy", () => { + const auth = toAiError({ module: "T", method: "m", error: apiCallError(401) }); + expect(classifyError(auth)).toBe("auth"); + expect(isTransient(auth)).toBe(false); + + const throttled = toAiError({ module: "T", method: "m", error: apiCallError(429) }); + expect(classifyError(throttled)).toBe("rate-limit"); + expect(isTransient(throttled)).toBe(true); + + const upstream = toAiError({ module: "T", method: "m", error: apiCallError(503) }); + expect(classifyError(upstream)).toBe("provider-unavailable"); + expect(isTransient(upstream)).toBe(true); + + const network = toAiError({ module: "T", method: "m", error: apiCallError(undefined) }); + expect(classifyError(network)).toBe("transport"); + expect(isTransient(network)).toBe(true); + }); + + it("classifies raw AI SDK errors after mapping through toAiError", () => { + const map = (error: unknown) => classifyError(toAiError({ module: "T", method: "m", error })); + expect(map(apiCallError(403))).toBe("auth"); + expect(map(apiCallError(undefined))).toBe("transport"); + expect(map(new JSONParseError({ text: "x", cause: new Error("y") }))).toBe( + "malformed-response", + ); + expect(map(new Error("boom"))).toBe("unknown"); + }); + + it("classifies malformed output as terminal", () => { + const malformed = new AiError.MalformedOutput({ module: "T", method: "m" }); + expect(classifyError(malformed)).toBe("malformed-response"); + expect(isTransient(malformed)).toBe(false); + }); +}); diff --git a/packages/effect/test/language-model.test.ts b/packages/effect/test/language-model.test.ts new file mode 100644 index 0000000..a2045f2 --- /dev/null +++ b/packages/effect/test/language-model.test.ts @@ -0,0 +1,437 @@ +import { + APICallError, + type LanguageModelV4, + type LanguageModelV4CallOptions, + type LanguageModelV4StreamPart, +} from "@ai-sdk/provider"; +import * as AiError from "@effect/ai/AiError"; +import type * as LanguageModel from "@effect/ai/LanguageModel"; +import type * as Prompt from "@effect/ai/Prompt"; +import * as Tool from "@effect/ai/Tool"; +import * as Toolkit from "@effect/ai/Toolkit"; +import * as Chunk from "effect/Chunk"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { describe, expect, it } from "vitest"; +import { classifyError } from "../src/errors.js"; +import * as CoderLanguageModel from "../src/language-model.js"; + +const USAGE = { + inputTokens: { total: 10, noCache: 8, cacheRead: 2, cacheWrite: undefined }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, +}; + +const fakeModel = (overrides: Partial): LanguageModelV4 => ({ + specificationVersion: "v4", + provider: "coder", + modelId: "fake-model", + supportedUrls: {}, + doGenerate: () => { + throw new Error("doGenerate not stubbed"); + }, + doStream: () => { + throw new Error("doStream not stubbed"); + }, + ...overrides, +}); + +const service = (model: LanguageModelV4): Effect.Effect => + CoderLanguageModel.fromModel(model); + +const streamOf = (parts: ReadonlyArray) => + new ReadableStream({ + start(controller) { + for (const part of parts) controller.enqueue(part); + controller.close(); + }, + }); + +describe("generateText", () => { + it("maps a text response, finish reason, and usage", async () => { + let captured: LanguageModelV4CallOptions | undefined; + const model = fakeModel({ + doGenerate: async (options) => { + captured = options; + return { + content: [{ type: "text", text: "Hello from the gateway" }], + finishReason: { unified: "stop", raw: "stop" }, + usage: USAGE, + warnings: [], + response: { id: "resp_1", modelId: "gpt-test" }, + }; + }, + }); + + const response = await Effect.runPromise( + Effect.flatMap(service(model), (m) => m.generateText({ prompt: "Say hello" })), + ); + + expect(response.text).toBe("Hello from the gateway"); + expect(response.finishReason).toBe("stop"); + expect(response.usage.inputTokens).toBe(10); + expect(response.usage.outputTokens).toBe(5); + expect(response.usage.totalTokens).toBe(15); + expect(response.usage.cachedInputTokens).toBe(2); + expect(captured?.prompt).toEqual([ + { role: "user", content: [{ type: "text", text: "Say hello" }] }, + ]); + }); + + it("maps system/user/assistant/tool messages onto the spec prompt", async () => { + let captured: LanguageModelV4CallOptions | undefined; + const model = fakeModel({ + doGenerate: async (options) => { + captured = options; + return { + content: [{ type: "text", text: "ok" }], + finishReason: { unified: "stop", raw: undefined }, + usage: USAGE, + warnings: [], + }; + }, + }); + + const messages: Array = [ + { role: "system", content: "Be terse." }, + { role: "user", content: [{ type: "text", text: "What is the weather in Berlin?" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "User wants weather." }, + { + type: "tool-call", + id: "call_1", + name: "get_weather", + params: { city: "Berlin" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + id: "call_1", + name: "get_weather", + isFailure: false, + result: { temperature: 21 }, + providerExecuted: false, + }, + ], + }, + ]; + + await Effect.runPromise( + Effect.flatMap(service(model), (m) => m.generateText({ prompt: messages })), + ); + + expect(captured?.prompt).toEqual([ + { role: "system", content: "Be terse." }, + { role: "user", content: [{ type: "text", text: "What is the weather in Berlin?" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "User wants weather." }, + { + type: "tool-call", + toolCallId: "call_1", + toolName: "get_weather", + input: { city: "Berlin" }, + providerExecuted: false, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "get_weather", + output: { type: "json", value: { temperature: 21 } }, + }, + ], + }, + ]); + }); + + it("sends tool definitions as JSON schema and resolves tool calls", async () => { + const GetWeather = Tool.make("get_weather", { + description: "Look up the current weather", + parameters: { city: Schema.String }, + success: Schema.Struct({ temperature: Schema.Number }), + }); + const toolkit = Toolkit.make(GetWeather); + + let captured: LanguageModelV4CallOptions | undefined; + const model = fakeModel({ + doGenerate: async (options) => { + captured = options; + return { + content: [ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "get_weather", + input: `{"city":"Berlin"}`, + }, + ], + finishReason: { unified: "tool-calls", raw: "tool_calls" }, + usage: USAGE, + warnings: [], + }; + }, + }); + + const response = await Effect.runPromise( + Effect.flatMap(service(model), (m) => m.generateText({ prompt: "weather?", toolkit })).pipe( + Effect.provide( + toolkit.toLayer({ + get_weather: ({ city }) => Effect.succeed({ temperature: city === "Berlin" ? 21 : 0 }), + }), + ), + ), + ); + + expect(captured?.tools).toEqual([ + { + type: "function", + name: "get_weather", + description: "Look up the current weather", + inputSchema: { + type: "object", + required: ["city"], + properties: { city: { type: "string" } }, + additionalProperties: false, + }, + }, + ]); + expect(captured?.toolChoice).toEqual({ type: "auto" }); + expect(response.toolCalls).toHaveLength(1); + expect(response.toolResults).toHaveLength(1); + expect(response.toolResults[0]?.result).toEqual({ temperature: 21 }); + }); + + it("fails with MalformedInput for the oneOf tool choice mode", async () => { + const GetWeather = Tool.make("get_weather", { + parameters: { city: Schema.String }, + success: Schema.Struct({ temperature: Schema.Number }), + }); + const toolkit = Toolkit.make(GetWeather); + const model = fakeModel({}); + + const exit = await Effect.runPromiseExit( + Effect.flatMap(service(model), (m) => + m.generateText({ prompt: "weather?", toolkit, toolChoice: { oneOf: ["get_weather"] } }), + ).pipe( + Effect.provide(toolkit.toLayer({ get_weather: () => Effect.succeed({ temperature: 0 }) })), + ), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(String(exit)).toContain("MalformedInput"); + expect(String(exit)).toContain("oneOf"); + }); + + it("fails with MalformedOutput when tool call arguments are not JSON", async () => { + const model = fakeModel({ + doGenerate: async () => ({ + content: [ + { type: "tool-call", toolCallId: "call_1", toolName: "broken", input: "{not json" }, + ], + finishReason: { unified: "tool-calls", raw: undefined }, + usage: USAGE, + warnings: [], + }), + }); + + const exit = await Effect.runPromiseExit( + Effect.flatMap(service(model), (m) => + m.generateText({ prompt: "x", disableToolCallResolution: true }), + ), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(String(exit)).toContain("MalformedOutput"); + }); + + it("maps HTTP failures to the typed AiError taxonomy", async () => { + const failWith = (statusCode?: number) => + fakeModel({ + doGenerate: async () => { + throw new APICallError({ + message: "failed", + url: "https://coder.example.com/gateway", + requestBodyValues: {}, + statusCode, + responseBody: "denied", + }); + }, + }); + + const run = (statusCode?: number) => + Effect.runPromiseExit( + Effect.flatMap(service(failWith(statusCode)), (m) => m.generateText({ prompt: "x" })), + ); + + const auth = await run(401); + expect(Exit.isFailure(auth)).toBe(true); + if (Exit.isFailure(auth) && auth.cause._tag === "Fail") { + const error = auth.cause.error; + expect(error._tag).toBe("HttpResponseError"); + expect(classifyError(error)).toBe("auth"); + } + + const throttled = await run(429); + if (Exit.isFailure(throttled) && throttled.cause._tag === "Fail") { + expect(classifyError(throttled.cause.error)).toBe("rate-limit"); + } + + const network = await run(undefined); + if (Exit.isFailure(network) && network.cause._tag === "Fail") { + expect(network.cause.error._tag).toBe("HttpRequestError"); + expect(classifyError(network.cause.error)).toBe("transport"); + } + }); +}); + +describe("generateObject", () => { + it("passes the JSON response format and decodes the value", async () => { + let captured: LanguageModelV4CallOptions | undefined; + const model = fakeModel({ + doGenerate: async (options) => { + captured = options; + return { + content: [{ type: "text", text: `{"city":"Berlin","temperature":21}` }], + finishReason: { unified: "stop", raw: undefined }, + usage: USAGE, + warnings: [], + }; + }, + }); + + const Weather = Schema.Struct({ city: Schema.String, temperature: Schema.Number }); + const response = await Effect.runPromise( + Effect.flatMap(service(model), (m) => + m.generateObject({ prompt: "weather in berlin", schema: Weather, objectName: "weather" }), + ), + ); + + expect(response.value).toEqual({ city: "Berlin", temperature: 21 }); + expect(captured?.responseFormat).toEqual({ + type: "json", + name: "weather", + schema: { + type: "object", + required: ["city", "temperature"], + properties: { city: { type: "string" }, temperature: { type: "number" } }, + additionalProperties: false, + }, + }); + }); +}); + +describe("streamText", () => { + it("maps stream parts onto @effect/ai stream parts", async () => { + const model = fakeModel({ + doStream: async () => ({ + stream: streamOf([ + { type: "stream-start", warnings: [] }, + { type: "response-metadata", id: "resp_1", modelId: "gpt-test" }, + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "Hel" }, + { type: "text-delta", id: "t1", delta: "lo" }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, + ]), + }), + }); + + const parts = await Effect.runPromise( + Effect.flatMap(service(model), (m) => Stream.runCollect(m.streamText({ prompt: "hi" }))), + ); + + const types = Chunk.toReadonlyArray(parts).map((part) => part.type); + expect(types).toEqual([ + "response-metadata", + "text-start", + "text-delta", + "text-delta", + "text-end", + "finish", + ]); + const text = Chunk.toReadonlyArray(parts) + .flatMap((part) => (part.type === "text-delta" ? [part.delta] : [])) + .join(""); + expect(text).toBe("Hello"); + }); + + it("fails the stream with a typed AiError on error parts", async () => { + const model = fakeModel({ + doStream: async () => ({ + stream: streamOf([ + { type: "text-start", id: "t1" }, + { + type: "error", + error: new APICallError({ + message: "upstream exploded", + url: "https://coder.example.com/gateway", + requestBodyValues: {}, + statusCode: 503, + }), + }, + ]), + }), + }); + + const exit = await Effect.runPromiseExit( + Effect.flatMap(service(model), (m) => Stream.runCollect(m.streamText({ prompt: "hi" }))), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit) && exit.cause._tag === "Fail") { + expect(AiError.isAiError(exit.cause.error)).toBe(true); + expect(classifyError(exit.cause.error)).toBe("provider-unavailable"); + } + }); + + it("aborts the underlying request when the consuming fiber is interrupted", async () => { + let signal: AbortSignal | undefined; + const model = fakeModel({ + doStream: async (options) => { + signal = options.abortSignal; + return { + // A stream that never produces a part and never closes. + stream: new ReadableStream({ + pull: () => new Promise(() => {}), + }), + }; + }, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const m = yield* service(model); + const fiber = yield* Effect.fork(Stream.runCollect(m.streamText({ prompt: "hi" }))); + // Give the stream a chance to start and issue the request. + yield* Effect.promise( + () => + new Promise((resolve) => { + const check = () => { + if (signal !== undefined) return resolve(); + setTimeout(check, 5); + }; + check(); + }), + ); + yield* Fiber.interrupt(fiber); + }), + ); + + expect(signal).toBeDefined(); + expect(signal?.aborted).toBe(true); + }); +}); diff --git a/packages/effect/test/sandbox.test.ts b/packages/effect/test/sandbox.test.ts new file mode 100644 index 0000000..3303d09 --- /dev/null +++ b/packages/effect/test/sandbox.test.ts @@ -0,0 +1,232 @@ +import type { + CoderTransport, + CreateWorkspaceOptions, + ExecResult, + ListPresetsOptions, + PortForward, + PresetInfo, + SpawnedProcess, + TransportExecOptions, + WorkspaceStatus, +} from "@coder/ai-sdk-sandbox"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import { describe, expect, it } from "vitest"; +import { acquireSession, acquireWorkspace, CoderSandboxError } from "../src/sandbox.js"; + +const readyStatus = (name: string): WorkspaceStatus => ({ + id: "11111111-2222-3333-4444-555555555555", + name, + buildStatus: "running", + transition: "start", + agents: [{ name: "main", status: "connected", lifecycleState: "ready" }], +}); + +/** + * In-memory `CoderTransport` fake. Tracks lifecycle calls; `exists` controls + * whether the workspace pre-exists. + */ +class FakeTransport implements CoderTransport { + readonly calls: Array = []; + exists: boolean; + failStatus = false; + + constructor(options: { exists: boolean }) { + this.exists = options.exists; + } + + async exec(options: TransportExecOptions): Promise { + this.calls.push(`exec:${options.command}`); + return { exitCode: 0, stdout: "/home/coder", stderr: "" }; + } + spawn(_options: TransportExecOptions): SpawnedProcess { + throw new Error("spawn is not used in these tests"); + } + forwardPort(): Promise { + throw new Error("forwardPort is not used in these tests"); + } + async start(workspace: string): Promise { + this.calls.push(`start:${workspace}`); + } + async stop(workspace: string): Promise { + this.calls.push(`stop:${workspace}`); + } + async destroy(workspace: string): Promise { + this.calls.push(`destroy:${workspace}`); + } + async status(workspace: string): Promise { + this.calls.push(`status:${workspace}`); + if (this.failStatus) throw new Error("status exploded"); + return this.exists ? readyStatus(workspace) : null; + } + async create(options: CreateWorkspaceOptions): Promise { + this.calls.push(`create:${options.workspace}`); + this.exists = true; + } + async listPresets(_options: ListPresetsOptions): Promise { + return []; + } +} + +const scopedAcquire = ( + transport: FakeTransport, + teardown?: "delete-if-created" | "stop-if-created" | "keep", +) => + Effect.scoped( + Effect.andThen( + acquireWorkspace({ + workspace: "spike-ws", + create: { template: "docker", validate: false }, + transport, + teardown, + }), + (workspace) => Effect.succeed(workspace), + ), + ); + +describe("acquireWorkspace", () => { + it("creates a missing workspace and deletes it on scope close (default policy)", async () => { + const transport = new FakeTransport({ exists: false }); + const workspace = await Effect.runPromise(scopedAcquire(transport)); + + expect(workspace.created).toBe(true); + expect(transport.calls).toContain("create:spike-ws"); + expect(transport.calls).toContain("destroy:spike-ws"); + expect(transport.calls).not.toContain("stop:spike-ws"); + }); + + it("never tears down a pre-existing workspace it merely attached to", async () => { + const transport = new FakeTransport({ exists: true }); + const workspace = await Effect.runPromise(scopedAcquire(transport)); + + expect(workspace.created).toBe(false); + expect(transport.calls).not.toContain("destroy:spike-ws"); + expect(transport.calls).not.toContain("stop:spike-ws"); + }); + + it("honors the stop-if-created and keep policies", async () => { + const stopped = new FakeTransport({ exists: false }); + await Effect.runPromise(scopedAcquire(stopped, "stop-if-created")); + expect(stopped.calls).toContain("stop:spike-ws"); + expect(stopped.calls).not.toContain("destroy:spike-ws"); + + const kept = new FakeTransport({ exists: false }); + await Effect.runPromise(scopedAcquire(kept, "keep")); + expect(kept.calls).not.toContain("stop:spike-ws"); + expect(kept.calls).not.toContain("destroy:spike-ws"); + }); + + it("wraps acquisition failures in CoderSandboxError", async () => { + const transport = new FakeTransport({ exists: false }); + transport.failStatus = true; + + const exit = await Effect.runPromiseExit(scopedAcquire(transport)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit) && exit.cause._tag === "Fail") { + expect(exit.cause.error).toBeInstanceOf(CoderSandboxError); + expect(exit.cause.error.phase).toBe("acquire"); + } + }); + + it("releases the workspace when the fiber is interrupted after acquisition", async () => { + const transport = new FakeTransport({ exists: false }); + + await Effect.runPromise( + Effect.gen(function* () { + const fiber = yield* Effect.fork( + Effect.scoped( + Effect.andThen( + acquireWorkspace({ + workspace: "spike-ws", + create: { template: "docker", validate: false }, + transport, + }), + // Hold the scope open until interrupted. + Effect.never, + ), + ), + ); + yield* Effect.promise( + () => + new Promise((resolve) => { + const check = () => { + if (transport.calls.includes("create:spike-ws")) return resolve(); + setTimeout(check, 5); + }; + check(); + }), + ); + yield* Fiber.interrupt(fiber); + }), + ); + + expect(transport.calls).toContain("destroy:spike-ws"); + }); +}); + +describe("acquireSession", () => { + const settings = (transport: FakeTransport) => ({ + workspace: "spike-ws", + transport, + defaultWorkingDirectory: "/home/coder", + ownsLifecycle: true, + }); + + it("stops the session on scope close by default", async () => { + const transport = new FakeTransport({ exists: true }); + const id = await Effect.runPromise( + Effect.scoped( + Effect.map(acquireSession({ settings: settings(transport) }), (session) => session.id), + ), + ); + + expect(id).toBe("spike-ws"); + expect(transport.calls).toContain("stop:spike-ws"); + expect(transport.calls).not.toContain("destroy:spike-ws"); + }); + + it("destroys the session when configured", async () => { + const transport = new FakeTransport({ exists: true }); + await Effect.runPromise( + Effect.scoped(acquireSession({ settings: settings(transport), teardown: "destroy" })), + ); + + expect(transport.calls).toContain("destroy:spike-ws"); + }); + + it("releases the session when the fiber is interrupted after acquisition", async () => { + const transport = new FakeTransport({ exists: true }); + // Wrap-mode acquisition makes no transport calls, so signal it explicitly. + let acquired = false; + + await Effect.runPromise( + Effect.gen(function* () { + const fiber = yield* Effect.fork( + Effect.scoped( + Effect.andThen( + acquireSession({ settings: settings(transport) }), + Effect.suspend(() => { + acquired = true; + return Effect.never; + }), + ), + ), + ); + yield* Effect.promise( + () => + new Promise((resolve) => { + const check = () => { + if (acquired) return resolve(); + setTimeout(check, 5); + }; + check(); + }), + ); + yield* Fiber.interrupt(fiber); + }), + ); + + expect(transport.calls).toContain("stop:spike-ws"); + }); +}); diff --git a/packages/effect/test/schema.test.ts b/packages/effect/test/schema.test.ts new file mode 100644 index 0000000..4946470 --- /dev/null +++ b/packages/effect/test/schema.test.ts @@ -0,0 +1,58 @@ +import * as Schema from "effect/Schema"; +import { asSchema } from "ai"; +import { describe, expect, it } from "vitest"; +import { toAiSdkSchema, toJsonSchema } from "../src/schema.js"; + +const Weather = Schema.Struct({ + city: Schema.String, + temperature: Schema.Number, +}); + +describe("toJsonSchema", () => { + it("derives a draft-07 JSON schema from an Effect Schema", () => { + expect(toJsonSchema(Weather)).toEqual({ + type: "object", + required: ["city", "temperature"], + properties: { + city: { type: "string" }, + temperature: { type: "number" }, + }, + additionalProperties: false, + }); + }); +}); + +describe("toAiSdkSchema", () => { + it("is accepted by the AI SDK's schema surface with the derived JSON schema", () => { + const schema = asSchema(toAiSdkSchema(Weather)); + expect(schema.jsonSchema).toEqual(toJsonSchema(Weather)); + }); + + it("validates and decodes through the Effect Schema (round trip)", async () => { + // A transforming schema proves values are *decoded*, not just checked. + const Transforming = Schema.Struct({ + city: Schema.String, + temperature: Schema.NumberFromString, + }); + const schema = toAiSdkSchema(Transforming); + const result = await schema.validate?.({ city: "Berlin", temperature: "21" }); + expect(result).toEqual({ success: true, value: { city: "Berlin", temperature: 21 } }); + }); + + it("reports schema violations as validation failures", async () => { + const schema = toAiSdkSchema(Weather); + const result = await schema.validate?.({ city: "Berlin" }); + expect(result?.success).toBe(false); + if (result?.success === false) { + expect(result.error.message).toContain("temperature"); + } + }); + + it("exists because bare Effect standard schemas cannot produce JSON schema", () => { + // Pin the gap that motivates this bridge: `Schema.standardSchemaV1` emits + // no `~standard.jsonSchema` converter, so the AI SDK cannot derive the + // wire schema from it. If this starts passing, the bridge can be retired. + const standard = asSchema(Schema.standardSchemaV1(Weather)); + expect(() => standard.jsonSchema).toThrow(/does not support JSON Schema conversion/); + }); +}); diff --git a/packages/effect/tsconfig.json b/packages/effect/tsconfig.json new file mode 100644 index 0000000..c512707 --- /dev/null +++ b/packages/effect/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "moduleDetection": "force", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "resolveJsonModule": true, + "noFallthroughCasesInSwitch": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"] + }, + "include": ["src", "test", "examples"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/effect/tsup.config.ts b/packages/effect/tsup.config.ts new file mode 100644 index 0000000..33d286b --- /dev/null +++ b/packages/effect/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + clean: true, + target: "es2022", + external: ["ai", "effect", /^effect\//, /^@effect\//, /^@ai-sdk\//, /^@coder\//], +}); diff --git a/packages/effect/vitest.config.ts b/packages/effect/vitest.config.ts new file mode 100644 index 0000000..32f5758 --- /dev/null +++ b/packages/effect/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + testTimeout: 30_000, + hookTimeout: 30_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b8aa1e..5f10c7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,64 @@ importers: specifier: 4.5.1 version: 4.5.1 + packages/effect: + dependencies: + '@ai-sdk/provider': + specifier: 4.0.8 + version: 4.0.8 + '@coder/ai-sdk-provider': + specifier: 0.4.4 + version: 0.4.4(ai@7.0.84(zod@4.5.1))(zod@4.5.1) + '@coder/ai-sdk-sandbox': + specifier: 0.4.7 + version: 0.4.7(@ai-sdk/harness@1.0.93(ws@8.21.3)(zod@4.5.1))(@ai-sdk/provider-utils@5.0.33(zod@4.5.1)) + '@effect/ai': + specifier: 0.37.0 + version: 0.37.0(@effect/experimental@0.61.1(@effect/platform@0.97.1(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.97.1(effect@3.22.1))(@effect/rpc@0.76.2(@effect/platform@0.97.1(effect@3.22.1))(effect@3.22.1))(effect@3.22.1) + effect: + specifier: 3.22.1 + version: 3.22.1 + devDependencies: + '@ai-sdk/harness': + specifier: ^1.0.93 + version: 1.0.93(ws@8.21.3)(zod@4.5.1) + '@ai-sdk/provider-utils': + specifier: ^5.0.33 + version: 5.0.33(zod@4.5.1) + '@effect/experimental': + specifier: 0.61.1 + version: 0.61.1(@effect/platform@0.97.1(effect@3.22.1))(effect@3.22.1) + '@effect/platform': + specifier: 0.97.1 + version: 0.97.1(effect@3.22.1) + '@effect/rpc': + specifier: 0.76.2 + version: 0.76.2(@effect/platform@0.97.1(effect@3.22.1))(effect@3.22.1) + '@types/node': + specifier: ^26.4.0 + version: 26.4.0 + '@vitest/coverage-v8': + specifier: ^4.1.11 + version: 4.1.11(vitest@4.1.11) + ai: + specifier: ^7.0.84 + version: 7.0.84(zod@4.5.1) + tsup: + specifier: ^8.5.1 + version: 8.5.1(postcss@8.5.26)(supports-color@7.2.0)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) + tsx: + specifier: ^4.23.12 + version: 4.23.12 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.11 + version: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) + zod: + specifier: 4.5.1 + version: 4.5.1 + packages/provider: dependencies: '@ai-sdk/anthropic': @@ -284,6 +342,23 @@ packages: '@braidai/lang@1.1.2': resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} + '@coder/ai-sdk-provider@0.4.4': + resolution: {integrity: sha512-vT4ZiX+17+to5cp0CnQYw4+593NmzKJmS6IF8icyuHFIM0wL2O5oaspOOVxcfc30utxDWOI58fBT9gDabxLSUQ==} + engines: {node: '>=22'} + peerDependencies: + ai: ^7.0.0 + zod: 4.5.1 + peerDependenciesMeta: + zod: + optional: true + + '@coder/ai-sdk-sandbox@0.4.7': + resolution: {integrity: sha512-Z0+FRPrGUU2d2DltAjHqdXzyrW1qDgAS/fQvzZpCkJk0a2z0QteRfhDoPE10yPY4sXV+aXVvYw1Shs4ewoaOYA==} + engines: {node: '>=22'} + peerDependencies: + '@ai-sdk/harness': ^1.0.0 + '@ai-sdk/provider-utils': ^5.0.0 + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -291,6 +366,38 @@ packages: '@conventional-commits/parser@0.4.1': resolution: {integrity: sha512-H2ZmUVt6q+KBccXfMBhbBF14NlANeqHTXL4qCL6QGbMzrc4HDXyzWuxPxPNbz71f/5UkR5DrycP5VO9u7crahg==} + '@effect/ai@0.37.0': + resolution: {integrity: sha512-tHkDg/p2tNxKhXZATOokl5vGGqrV7K5FA/qdIExRTjFUOEVvKCWoVxqtUuj+NAp9BeSVuysJMPrHB9XjFPsUiQ==} + peerDependencies: + '@effect/experimental': ^0.61.0 + '@effect/platform': ^0.97.0 + '@effect/rpc': ^0.76.0 + effect: ^3.22.0 + + '@effect/experimental@0.61.1': + resolution: {integrity: sha512-+P+PgGQeE2fXmsm63YoyENNcQIc7OiCkY4/HLkEdTvq93syfGIpwDfQD8u3xODMHJhZ5ZtRZHH4+UedFO0tLiA==} + peerDependencies: + '@effect/platform': ^0.97.1 + effect: ^3.22.1 + ioredis: ^5 + lmdb: ^3 + peerDependenciesMeta: + ioredis: + optional: true + lmdb: + optional: true + + '@effect/platform@0.97.1': + resolution: {integrity: sha512-IevWxwmrxCdeXxbXDx/hiOstHtERRigd1nhZSuHgiFEl7NkBmS/5GVWsRacq4NfJN2uCXqggETKNwij15VEpew==} + peerDependencies: + effect: ^3.22.1 + + '@effect/rpc@0.76.2': + resolution: {integrity: sha512-x/4jvjufr1m3QWAace7G5Xh09DDcaeU7ijvS0Qumkan/hM89n19JClYnlV3IW93RACZInbgdyOVA+4kOQJ+JQQ==} + peerDependencies: + '@effect/platform': ^0.97.1 + effect: ^3.22.1 + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -482,6 +589,36 @@ packages: '@loaderkit/resolve@1.0.6': resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -1344,6 +1481,9 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} + effect@3.22.1: + resolution: {integrity: sha512-TNoXushmPOBAjJlthF5d2QwnX2xBPEtcNJr5XKNKbRLbDvBcOYkXlYDfvGfSA0zriwLFuCll5MDtNMAdZL17PQ==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1392,6 +1532,10 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1408,6 +1552,9 @@ packages: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -1705,6 +1852,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@1.12.1: + resolution: {integrity: sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==} + + multipasta@0.2.8: + resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -1720,6 +1877,10 @@ packages: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-html-parser@6.1.13: resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==} @@ -1860,6 +2021,9 @@ packages: engines: {node: '>=18'} hasBin: true + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + quick-lru@4.0.1: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} engines: {node: '>=8'} @@ -2149,6 +2313,10 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -2432,6 +2600,25 @@ snapshots: '@braidai/lang@1.1.2': {} + '@coder/ai-sdk-provider@0.4.4(ai@7.0.84(zod@4.5.1))(zod@4.5.1)': + dependencies: + '@ai-sdk/anthropic': 4.0.45(zod@4.5.1) + '@ai-sdk/openai-compatible': 3.0.40(zod@4.5.1) + '@ai-sdk/provider': 4.0.8 + ai: 7.0.84(zod@4.5.1) + optionalDependencies: + zod: 4.5.1 + + '@coder/ai-sdk-sandbox@0.4.7(@ai-sdk/harness@1.0.93(ws@8.21.3)(zod@4.5.1))(@ai-sdk/provider-utils@5.0.33(zod@4.5.1))': + dependencies: + '@ai-sdk/harness': 1.0.93(ws@8.21.3)(zod@4.5.1) + '@ai-sdk/provider-utils': 5.0.33(zod@4.5.1) + ws: 8.21.3 + yaml: 2.9.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@colors/colors@1.5.0': optional: true @@ -2440,6 +2627,33 @@ snapshots: unist-util-visit: 2.0.3 unist-util-visit-parents: 3.1.1 + '@effect/ai@0.37.0(@effect/experimental@0.61.1(@effect/platform@0.97.1(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.97.1(effect@3.22.1))(@effect/rpc@0.76.2(@effect/platform@0.97.1(effect@3.22.1))(effect@3.22.1))(effect@3.22.1)': + dependencies: + '@effect/experimental': 0.61.1(@effect/platform@0.97.1(effect@3.22.1))(effect@3.22.1) + '@effect/platform': 0.97.1(effect@3.22.1) + '@effect/rpc': 0.76.2(@effect/platform@0.97.1(effect@3.22.1))(effect@3.22.1) + effect: 3.22.1 + find-my-way-ts: 0.1.6 + + '@effect/experimental@0.61.1(@effect/platform@0.97.1(effect@3.22.1))(effect@3.22.1)': + dependencies: + '@effect/platform': 0.97.1(effect@3.22.1) + effect: 3.22.1 + uuid: 11.1.1 + + '@effect/platform@0.97.1(effect@3.22.1)': + dependencies: + effect: 3.22.1 + find-my-way-ts: 0.1.6 + msgpackr: 1.12.1 + multipasta: 0.2.8 + + '@effect/rpc@0.76.2(@effect/platform@0.97.1(effect@3.22.1))(effect@3.22.1)': + dependencies: + '@effect/platform': 0.97.1(effect@3.22.1) + effect: 3.22.1 + msgpackr: 1.12.1 + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -2551,6 +2765,24 @@ snapshots: dependencies: '@braidai/lang': 1.1.2 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true @@ -3163,6 +3395,11 @@ snapshots: dependencies: is-obj: 2.0.0 + effect@3.22.1: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + emoji-regex@8.0.0: {} emojilib@2.4.0: {} @@ -3220,6 +3457,10 @@ snapshots: expect-type@1.4.0: {} + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -3230,6 +3471,8 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + find-my-way-ts@0.1.6: {} + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -3485,6 +3728,24 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@1.12.1: + optionalDependencies: + msgpackr-extract: 3.0.4 + + multipasta@0.2.8: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -3502,6 +3763,11 @@ snapshots: emojilib: 2.4.0 skin-tone: 2.0.0 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-html-parser@6.1.13: dependencies: css-select: 5.2.2 @@ -3651,6 +3917,8 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 + pure-rand@6.1.0: {} + quick-lru@4.0.1: {} react@19.2.8: {} @@ -3976,6 +4244,8 @@ snapshots: dependencies: react: 19.2.8 + uuid@11.1.1: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index db7b3c5..fb4ef48 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,10 @@ packages: # uses `allowBuilds` (a name -> bool map) to approve specific packages. allowBuilds: esbuild: true + # Optional native accelerator for msgpackr (via @effect/experimental, a type + # peer of @effect/ai). Never loaded by @coder/ai-sdk-effect; deny the build + # and rely on msgpackr's pure-JS fallback. + msgpackr-extract: false overrides: # Force the patched esbuild across the whole workspace. tsup pins a vulnerable @@ -30,3 +34,5 @@ minimumReleaseAgeExclude: - "@ai-sdk/tui" - "ai" - "@ai-sdk/mcp@2.0.32 || 2.0.37 || 2.0.39 || 2.0.40" + - "@coder/ai-sdk-provider@0.4.4" + - "@coder/ai-sdk-sandbox@0.4.7" From bd74e7ec5d5d71181a9d857edcc0de8e1492fdab Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 30 Aug 2026 11:29:45 +0000 Subject: [PATCH 2/6] feat(effect): inject json instruction for schema-less json modes Change-Id: I3855ff7f38213b954d3fd375ebd797ec48f84e20 Signed-off-by: Thomas Kosiewski --- packages/effect/src/language-model.ts | 25 ++++++++++++++++----- packages/effect/test/language-model.test.ts | 7 ++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/effect/src/language-model.ts b/packages/effect/src/language-model.ts index 969ca2c..e92c3df 100644 --- a/packages/effect/src/language-model.ts +++ b/packages/effect/src/language-model.ts @@ -181,21 +181,34 @@ const buildCallOptions = ( ): Effect.Effect => Effect.try({ try: () => { - const callOptions: LanguageModelV4CallOptions = { - prompt: promptToV4(options.prompt), - }; + const prompt = promptToV4(options.prompt); + const callOptions: LanguageModelV4CallOptions = { prompt }; if (options.tools.length > 0) { callOptions.tools = options.tools.map((tool) => toolToV4(tool, method)); callOptions.toolChoice = toolChoiceToV4(options.toolChoice, method); } if (options.responseFormat.type === "json") { + // SAFETY: effect's JsonSchema7 output is structurally a JSON Schema + // draft-07 document; only the nominal type differs from `JSONSchema7`. + const schema = Tool.getJsonSchemaFromSchemaAst( + options.responseFormat.schema.ast, + ) as JSONSchema7; callOptions.responseFormat = { type: "json", name: options.responseFormat.objectName, - // SAFETY: effect's JsonSchema7 output is structurally a JSON Schema - // draft-07 document; only the nominal type differs from `JSONSchema7`. - schema: Tool.getJsonSchemaFromSchemaAst(options.responseFormat.schema.ast) as JSONSchema7, + schema, }; + // Models without native structured-output support fall back to a + // schema-less JSON mode (OpenAI's `json_object`), which requires the + // word "JSON" in the messages and gets no schema on the wire. Inject + // the schema as a leading system instruction — the same strategy the + // AI SDK's own `generateObject` uses. + prompt.unshift({ + role: "system", + content: + `JSON schema:\n${JSON.stringify(schema)}\n` + + `You MUST answer with a JSON object that matches the JSON schema above.`, + }); } return callOptions; }, diff --git a/packages/effect/test/language-model.test.ts b/packages/effect/test/language-model.test.ts index a2045f2..b84a966 100644 --- a/packages/effect/test/language-model.test.ts +++ b/packages/effect/test/language-model.test.ts @@ -331,6 +331,13 @@ describe("generateObject", () => { additionalProperties: false, }, }); + // Schema-less JSON modes (e.g. OpenAI json_object) need the instruction. + const first = captured?.prompt[0]; + expect(first?.role).toBe("system"); + if (first?.role === "system") { + expect(first.content).toContain("JSON schema:"); + expect(first.content).toContain(`"city"`); + } }); }); From 7c27e84575005aaf8546dd8735131dc88e78cad8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 30 Aug 2026 11:43:03 +0000 Subject: [PATCH 3/6] fix(effect): address codex review findings Change-Id: Idb5cd17a17155b9a717ccc11761a36e8e48ba472 Signed-off-by: Thomas Kosiewski --- packages/effect/README.md | 12 ++++- packages/effect/src/errors.ts | 19 +++++--- packages/effect/src/index.ts | 1 + packages/effect/src/language-model.ts | 49 ++++++++++++++++----- packages/effect/src/sandbox.ts | 40 ++++++++++++++++- packages/effect/test/errors.test.ts | 13 +++--- packages/effect/test/language-model.test.ts | 34 ++++++++++++++ packages/effect/test/sandbox.test.ts | 45 ++++++++++++++++++- 8 files changed, 186 insertions(+), 27 deletions(-) diff --git a/packages/effect/README.md b/packages/effect/README.md index b5c0bad..21c60f7 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -40,7 +40,10 @@ decision. `CoderProviderSettings` or `{ provider }` for an existing `CoderProvider`, so both auth modes (centralized and BYOK) work unchanged. `generateText`, `generateObject`, and `streamText` are supported; structured outputs derive -their JSON schema from the Effect Schema you pass. +their JSON schema from the Effect Schema you pass. Generation controls +(`maxOutputTokens`, `temperature`, `topP`, `topK`, penalties, `stopSequences`, +`seed`, `reasoning`) are set at construction time via an optional third +`GenerationOptions` argument and forwarded on every call. ```ts import * as LanguageModel from "@effect/ai/LanguageModel"; @@ -157,9 +160,14 @@ equivalent are dropped: - Response parts of type `custom`, `reasoning-file`, and `tool-approval-request`, and file payloads that are not raw data (URL / provider-reference / inline-text), are dropped. -- `Prompt` provider options (per-part metadata) are not forwarded. +- `Prompt` provider options (per-part metadata) are not forwarded, and + generation controls are fixed at model construction — a per-call override + channel (an Effect config service, as `@effect/ai`'s own providers use) is + Phase 2. - Workspace acquisition is uninterruptible (standard `acquireRelease` semantics); a slow `ensureCoderWorkspace` cannot be cancelled mid-flight. + A workspace created by an acquisition that then fails (e.g. readiness + timeout) is rolled back best-effort per the teardown policy. - Telemetry: the `ProviderOptions.span` is not wired into request headers. ## Phase 2 (not in this package yet) diff --git a/packages/effect/src/errors.ts b/packages/effect/src/errors.ts index fa89e0b..9fe4539 100644 --- a/packages/effect/src/errors.ts +++ b/packages/effect/src/errors.ts @@ -12,6 +12,7 @@ * response, ...) from either side — useful with `Effect.retry` policies. */ import { + AISDKError, APICallError, EmptyResponseBodyError, InvalidArgumentError, @@ -48,11 +49,7 @@ export const classifyStatus = (status: number): ErrorReason => { return "unknown"; }; -/** - * Classify an `@effect/ai` `AiError` into an {@link ErrorReason}. Raw AI SDK - * errors are classified by first mapping them with {@link toAiError}. - */ -export const classifyError = (error: AiError.AiError): ErrorReason => { +const classifyAiError = (error: AiError.AiError): ErrorReason => { switch (error._tag) { case "HttpResponseError": return error.reason === "StatusCode" @@ -68,11 +65,21 @@ export const classifyError = (error: AiError.AiError): ErrorReason => { } }; +/** + * Classify an error into an {@link ErrorReason}: either an `@effect/ai` + * `AiError` produced by this bridge, or a raw AI SDK error (which is first + * mapped with {@link toAiError}). + */ +export const classifyError = (error: AiError.AiError | AISDKError): ErrorReason => { + if (AiError.isAiError(error)) return classifyAiError(error); + return classifyAiError(toAiError({ module: "CoderAiError", method: "classifyError", error })); +}; + /** * Whether a failure is worth retrying (throttling, upstream outage, or a * network error). Auth and malformed-response failures are terminal. */ -export const isTransient = (error: AiError.AiError): boolean => { +export const isTransient = (error: AiError.AiError | AISDKError): boolean => { const reason = classifyError(error); return reason === "rate-limit" || reason === "provider-unavailable" || reason === "transport"; }; diff --git a/packages/effect/src/index.ts b/packages/effect/src/index.ts index 911460e..dc11a86 100644 --- a/packages/effect/src/index.ts +++ b/packages/effect/src/index.ts @@ -6,6 +6,7 @@ export { toAiError, } from "./errors.js"; export * as CoderLanguageModel from "./language-model.js"; +export type { GenerationOptions, ProviderSource } from "./language-model.js"; export { acquireSession, type AcquireSessionOptions, diff --git a/packages/effect/src/language-model.ts b/packages/effect/src/language-model.ts index e92c3df..1f73e41 100644 --- a/packages/effect/src/language-model.ts +++ b/packages/effect/src/language-model.ts @@ -51,20 +51,44 @@ const MODULE = "CoderLanguageModel"; /** The model source: provider settings, or an already-constructed provider. */ export type ProviderSource = CoderProviderSettings | { readonly provider: CoderProvider }; +/** + * Generation controls forwarded to the underlying model on every call. These + * are fixed at construction time; a per-call override channel (an Effect + * config service, as `@effect/ai`'s own providers use) is Phase 2. + */ +export type GenerationOptions = Pick< + LanguageModelV4CallOptions, + | "maxOutputTokens" + | "temperature" + | "topP" + | "topK" + | "presencePenalty" + | "frequencyPenalty" + | "stopSequences" + | "seed" + | "reasoning" +>; + /** * Build a `LanguageModel` service from any AI SDK `LanguageModelV4`. This is * the bridge core; it performs no HTTP itself and is directly testable with a * fake model. */ -export const fromModel = (model: LanguageModelV4): Effect.Effect => +export const fromModel = ( + model: LanguageModelV4, + generation: GenerationOptions = {}, +): Effect.Effect => LanguageModel.make({ - generateText: (options) => generateText(model, options), - streamText: (options) => streamText(model, options), + generateText: (options) => generateText(model, options, generation), + streamText: (options) => streamText(model, options, generation), }); /** {@link fromModel} as a `Layer`. */ -export const layerFromModel = (model: LanguageModelV4): Layer.Layer => - Layer.effect(LanguageModel.LanguageModel, fromModel(model)); +export const layerFromModel = ( + model: LanguageModelV4, + generation: GenerationOptions = {}, +): Layer.Layer => + Layer.effect(LanguageModel.LanguageModel, fromModel(model, generation)); /** * Build a `LanguageModel` service for a Coder AI Gateway model. `source` is @@ -75,6 +99,7 @@ export const layerFromModel = (model: LanguageModelV4): Layer.Layer => Effect.flatMap( Effect.try({ @@ -90,15 +115,16 @@ export const make = ( cause: error, }), }), - fromModel, + (model) => fromModel(model, generation), ); /** {@link make} as a `Layer` providing `LanguageModel`. */ export const layer = ( modelId: string, source: ProviderSource, + generation: GenerationOptions = {}, ): Layer.Layer => - Layer.effect(LanguageModel.LanguageModel, make(modelId, source)); + Layer.effect(LanguageModel.LanguageModel, make(modelId, source, generation)); // --------------------------------------------------------------------------- // generateText / streamText implementations @@ -107,9 +133,10 @@ export const layer = ( const generateText = ( model: LanguageModelV4, options: LanguageModel.ProviderOptions, + generation: GenerationOptions, ): Effect.Effect, AiError.AiError> => Effect.gen(function* () { - const callOptions = yield* buildCallOptions(options, "generateText"); + const callOptions = yield* buildCallOptions(options, generation, "generateText"); const result = yield* Effect.tryPromise({ try: (signal) => model.doGenerate({ ...callOptions, abortSignal: signal }), catch: (error) => toAiError({ module: MODULE, method: "generateText", error }), @@ -133,10 +160,11 @@ const generateText = ( const streamText = ( model: LanguageModelV4, options: LanguageModel.ProviderOptions, + generation: GenerationOptions, ): Stream.Stream => Stream.unwrapScoped( Effect.gen(function* () { - const callOptions = yield* buildCallOptions(options, "streamText"); + const callOptions = yield* buildCallOptions(options, generation, "streamText"); // Tie request cancellation to the stream scope so that fiber // interruption aborts the underlying HTTP request. Aborting after a // normal end is a no-op. @@ -177,12 +205,13 @@ const unsupported = (method: string, description: string): AiError.MalformedInpu const buildCallOptions = ( options: LanguageModel.ProviderOptions, + generation: GenerationOptions, method: string, ): Effect.Effect => Effect.try({ try: () => { const prompt = promptToV4(options.prompt); - const callOptions: LanguageModelV4CallOptions = { prompt }; + const callOptions: LanguageModelV4CallOptions = { ...generation, prompt }; if (options.tools.length > 0) { callOptions.tools = options.tools.map((tool) => toolToV4(tool, method)); callOptions.toolChoice = toolChoiceToV4(options.toolChoice, method); diff --git a/packages/effect/src/sandbox.ts b/packages/effect/src/sandbox.ts index 22e2fe2..e755b1b 100644 --- a/packages/effect/src/sandbox.ts +++ b/packages/effect/src/sandbox.ts @@ -80,8 +80,19 @@ export const acquireWorkspace = ( try: async () => { // The same transport instance must perform acquisition and teardown. const transport = settings.transport ?? new CoderCliTransport(); - const workspace = await ensureCoderWorkspace({ ...settings, transport }); - return { workspace, transport }; + const preexisting = (await transport.status(options.workspace)) !== null; + try { + const workspace = await ensureCoderWorkspace({ ...settings, transport }); + return { workspace, transport }; + } catch (cause) { + // `ensureCoderWorkspace` may have created the workspace and then + // failed waiting for readiness. The release finalizer is only + // registered after successful acquisition, so roll the created + // workspace back here per the teardown policy — otherwise it + // would leak. + await rollbackFailedAcquisition(transport, options.workspace, preexisting, teardown); + throw cause; + } }, catch: (cause) => new CoderSandboxError({ @@ -97,6 +108,31 @@ export const acquireWorkspace = ( ); }; +/** + * Best-effort rollback of a workspace that a failed acquisition created. The + * original acquisition error always wins: rollback failures are swallowed + * (the workspace may leak, exactly as if no rollback had been attempted). + */ +const rollbackFailedAcquisition = async ( + transport: CoderTransport, + name: string, + preexisting: boolean, + teardown: WorkspaceTeardown, +): Promise => { + if (preexisting || teardown === "keep") return; + try { + // Only roll back a workspace that exists now but did not before. + if ((await transport.status(name)) === null) return; + if (teardown === "delete-if-created") { + await transport.destroy(name); + } else { + await transport.stop(name); + } + } catch { + // Deliberately ignored; the acquisition failure is re-thrown by the caller. + } +}; + const releaseWorkspace = ( transport: CoderTransport, workspace: EnsuredCoderWorkspace, diff --git a/packages/effect/test/errors.test.ts b/packages/effect/test/errors.test.ts index 83c706d..db72b7a 100644 --- a/packages/effect/test/errors.test.ts +++ b/packages/effect/test/errors.test.ts @@ -90,14 +90,15 @@ describe("classifyError / isTransient", () => { expect(isTransient(network)).toBe(true); }); - it("classifies raw AI SDK errors after mapping through toAiError", () => { - const map = (error: unknown) => classifyError(toAiError({ module: "T", method: "m", error })); - expect(map(apiCallError(403))).toBe("auth"); - expect(map(apiCallError(undefined))).toBe("transport"); - expect(map(new JSONParseError({ text: "x", cause: new Error("y") }))).toBe( + it("classifies raw AI SDK errors directly (documented contract)", () => { + expect(classifyError(apiCallError(403))).toBe("auth"); + expect(classifyError(apiCallError(429))).toBe("rate-limit"); + expect(classifyError(apiCallError(undefined))).toBe("transport"); + expect(classifyError(new JSONParseError({ text: "x", cause: new Error("y") }))).toBe( "malformed-response", ); - expect(map(new Error("boom"))).toBe("unknown"); + expect(isTransient(apiCallError(503))).toBe(true); + expect(isTransient(apiCallError(401))).toBe(false); }); it("classifies malformed output as terminal", () => { diff --git a/packages/effect/test/language-model.test.ts b/packages/effect/test/language-model.test.ts index b84a966..a877804 100644 --- a/packages/effect/test/language-model.test.ts +++ b/packages/effect/test/language-model.test.ts @@ -215,6 +215,40 @@ describe("generateText", () => { expect(response.toolResults[0]?.result).toEqual({ temperature: 21 }); }); + it("forwards generation controls to the underlying model", async () => { + let captured: LanguageModelV4CallOptions | undefined; + const model = fakeModel({ + doGenerate: async (options) => { + captured = options; + return { + content: [{ type: "text", text: "ok" }], + finishReason: { unified: "stop", raw: undefined }, + usage: USAGE, + warnings: [], + }; + }, + }); + + await Effect.runPromise( + Effect.flatMap( + CoderLanguageModel.fromModel(model, { + maxOutputTokens: 128, + temperature: 0, + topP: 0.9, + stopSequences: ["END"], + seed: 42, + }), + (m) => m.generateText({ prompt: "hi" }), + ), + ); + + expect(captured?.maxOutputTokens).toBe(128); + expect(captured?.temperature).toBe(0); + expect(captured?.topP).toBe(0.9); + expect(captured?.stopSequences).toEqual(["END"]); + expect(captured?.seed).toBe(42); + }); + it("fails with MalformedInput for the oneOf tool choice mode", async () => { const GetWeather = Tool.make("get_weather", { parameters: { city: Schema.String }, diff --git a/packages/effect/test/sandbox.test.ts b/packages/effect/test/sandbox.test.ts index 3303d09..f62232f 100644 --- a/packages/effect/test/sandbox.test.ts +++ b/packages/effect/test/sandbox.test.ts @@ -31,6 +31,8 @@ class FakeTransport implements CoderTransport { readonly calls: Array = []; exists: boolean; failStatus = false; + /** When true, created workspaces report a never-ready agent. */ + neverReady = false; constructor(options: { exists: boolean }) { this.exists = options.exists; @@ -58,7 +60,14 @@ class FakeTransport implements CoderTransport { async status(workspace: string): Promise { this.calls.push(`status:${workspace}`); if (this.failStatus) throw new Error("status exploded"); - return this.exists ? readyStatus(workspace) : null; + if (!this.exists) return null; + if (this.neverReady) { + return { + ...readyStatus(workspace), + agents: [{ name: "main", status: "connecting", lifecycleState: "starting" }], + }; + } + return readyStatus(workspace); } async create(options: CreateWorkspaceOptions): Promise { this.calls.push(`create:${options.workspace}`); @@ -117,6 +126,40 @@ describe("acquireWorkspace", () => { expect(kept.calls).not.toContain("destroy:spike-ws"); }); + it("rolls back a created workspace when readiness fails after creation", async () => { + const transport = new FakeTransport({ exists: false }); + transport.neverReady = true; + + const exit = await Effect.runPromiseExit( + Effect.scoped( + acquireWorkspace({ + workspace: "spike-ws", + create: { template: "docker", validate: false }, + readyTimeoutMs: 1, + transport, + }), + ), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(transport.calls).toContain("create:spike-ws"); + // The created-but-never-ready workspace must not leak. + expect(transport.calls).toContain("destroy:spike-ws"); + }); + + it("does not roll back a pre-existing workspace when acquisition fails", async () => { + const transport = new FakeTransport({ exists: true }); + transport.neverReady = true; + + const exit = await Effect.runPromiseExit( + Effect.scoped(acquireWorkspace({ workspace: "spike-ws", readyTimeoutMs: 1, transport })), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(transport.calls).not.toContain("destroy:spike-ws"); + expect(transport.calls).not.toContain("stop:spike-ws"); + }); + it("wraps acquisition failures in CoderSandboxError", async () => { const transport = new FakeTransport({ exists: false }); transport.failStatus = true; From 513c75f8456b1f4b07d6716ca4a9b9e1947f986c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 30 Aug 2026 11:54:22 +0000 Subject: [PATCH 4/6] fix(effect): address round-2 codex review findings Change-Id: I33abee1aa6bd669a0bc53db79f541f14e5d6010b Signed-off-by: Thomas Kosiewski --- AGENTS.md | 7 ++- packages/effect/src/sandbox.ts | 88 ++++++++++++++++++++++++---- packages/effect/test/sandbox.test.ts | 42 +++++++++++++ 3 files changed, 122 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8b58361..c5dd3db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,11 @@ this file covers what you need to operate the repo without tripping over it. pinned in `mise.toml` and locked in `mise.lock` — use `mise install`. - Three independently versioned npm packages, all targeting Vercel AI SDK v7: `@coder/ai-sdk-agent`, `@coder/ai-sdk-provider`, `@coder/ai-sdk-sandbox`. -- One private package: `packages/release-please-ai` — release-please run as a - library with AI-generated changelog notes; it drives the release workflow. +- Two private packages: `packages/release-please-ai` — release-please run as a + library with AI-generated changelog notes; it drives the release workflow — + and `packages/effect` (`@coder/ai-sdk-effect`) — an experimental Effect + bridge spike (issue #144), unpublished and outside release-please until it + graduates. ## Validation gates diff --git a/packages/effect/src/sandbox.ts b/packages/effect/src/sandbox.ts index e755b1b..41815ce 100644 --- a/packages/effect/src/sandbox.ts +++ b/packages/effect/src/sandbox.ts @@ -80,17 +80,25 @@ export const acquireWorkspace = ( try: async () => { // The same transport instance must perform acquisition and teardown. const transport = settings.transport ?? new CoderCliTransport(); - const preexisting = (await transport.status(options.workspace)) !== null; + const tracked = trackCreation(transport); try { - const workspace = await ensureCoderWorkspace({ ...settings, transport }); + const workspace = await ensureCoderWorkspace({ + ...settings, + transport: tracked.transport, + }); return { workspace, transport }; } catch (cause) { // `ensureCoderWorkspace` may have created the workspace and then // failed waiting for readiness. The release finalizer is only // registered after successful acquisition, so roll the created // workspace back here per the teardown policy — otherwise it - // would leak. - await rollbackFailedAcquisition(transport, options.workspace, preexisting, teardown); + // would leak. Ownership comes from observing our own successful + // `create` call, never from existence probing: a workspace that a + // concurrent caller created under the same name is not ours to + // touch. + if (tracked.createdByThisCall()) { + await rollbackFailedAcquisition(transport, options.workspace, teardown); + } throw cause; } }, @@ -108,6 +116,42 @@ export const acquireWorkspace = ( ); }; +/** + * Wrap a transport so a successful `create` call made *through this wrapper* + * is observable. This is the ownership signal for failed-acquisition rollback: + * it mirrors how the sandbox package itself derives `created`, and it can + * never mistake a concurrently created workspace for ours (in that race our + * own `create` call fails and ownership stays false). + */ +const trackCreation = ( + transport: CoderTransport, +): { + transport: CoderTransport; + createdByThisCall: () => boolean; + createdName: () => string | undefined; +} => { + let createdName: string | undefined; + const tracking: CoderTransport = { + exec: (options) => transport.exec(options), + spawn: (options) => transport.spawn(options), + forwardPort: (options) => transport.forwardPort(options), + start: (workspace, options) => transport.start(workspace, options), + stop: (workspace, options) => transport.stop(workspace, options), + destroy: (workspace, options) => transport.destroy(workspace, options), + status: (workspace, options) => transport.status(workspace, options), + listPresets: (options) => transport.listPresets(options), + create: async (options) => { + await transport.create(options); + createdName = options.workspace; + }, + }; + return { + transport: tracking, + createdByThisCall: () => createdName !== undefined, + createdName: () => createdName, + }; +}; + /** * Best-effort rollback of a workspace that a failed acquisition created. The * original acquisition error always wins: rollback failures are swallowed @@ -116,13 +160,10 @@ export const acquireWorkspace = ( const rollbackFailedAcquisition = async ( transport: CoderTransport, name: string, - preexisting: boolean, teardown: WorkspaceTeardown, ): Promise => { - if (preexisting || teardown === "keep") return; + if (teardown === "keep") return; try { - // Only roll back a workspace that exists now but did not before. - if ((await transport.status(name)) === null) return; if (teardown === "delete-if-created") { await transport.destroy(name); } else { @@ -204,11 +245,32 @@ export const acquireSession = ( return Effect.acquireRelease( Effect.tryPromise({ try: async () => { - const provider = createCoderWorkspace(options.settings); - const session = await provider.createSession({ sessionId: options.sessionId }); - // SAFETY: createCoderWorkspace always constructs CoderWorkspaceSession - // instances; the harness provider interface just types them loosely. - return session as CoderWorkspaceSession; + // Materialize the transport so a failed create-mode acquisition can be + // rolled back through the same instance. + const transport = options.settings.transport ?? new CoderCliTransport(); + const tracked = trackCreation(transport); + const provider = createCoderWorkspace({ + ...options.settings, + transport: tracked.transport, + }); + try { + const session = await provider.createSession({ sessionId: options.sessionId }); + // SAFETY: createCoderWorkspace always constructs CoderWorkspaceSession + // instances; the harness provider interface just types them loosely. + return session as CoderWorkspaceSession; + } catch (cause) { + // In create mode, `createSession` can create the workspace and then + // fail waiting for agent readiness — no session exists yet, so the + // release finalizer would never run and the workspace would leak. + // Destroy it if and only if our own `create` call succeeded and the + // provider owns the lifecycle (mirrors the provider's own rule that + // a created workspace is owned unless ownsLifecycle is false). + const createdName = tracked.createdName(); + if (createdName !== undefined && (options.settings.ownsLifecycle ?? true)) { + await rollbackFailedAcquisition(transport, createdName, "delete-if-created"); + } + throw cause; + } }, catch: (cause) => new CoderSandboxError({ diff --git a/packages/effect/test/sandbox.test.ts b/packages/effect/test/sandbox.test.ts index f62232f..7e694d7 100644 --- a/packages/effect/test/sandbox.test.ts +++ b/packages/effect/test/sandbox.test.ts @@ -147,6 +147,24 @@ describe("acquireWorkspace", () => { expect(transport.calls).toContain("destroy:spike-ws"); }); + it("never deletes a workspace it did not itself create (concurrent-creation race)", async () => { + // Simulates losing a create race: our status probe says the workspace is + // missing, but our own create call then fails (e.g. name conflict with + // `ifExists: "error"`). The other caller's workspace must not be touched. + const transport = new FakeTransport({ exists: false }); + transport.create = async (options: CreateWorkspaceOptions) => { + transport.calls.push(`create:${options.workspace}`); + throw new Error("a workspace with this name already exists"); + }; + + const exit = await Effect.runPromiseExit(scopedAcquire(transport)); + + expect(Exit.isFailure(exit)).toBe(true); + expect(transport.calls).toContain("create:spike-ws"); + expect(transport.calls).not.toContain("destroy:spike-ws"); + expect(transport.calls).not.toContain("stop:spike-ws"); + }); + it("does not roll back a pre-existing workspace when acquisition fails", async () => { const transport = new FakeTransport({ exists: true }); transport.neverReady = true; @@ -238,6 +256,30 @@ describe("acquireSession", () => { expect(transport.calls).toContain("destroy:spike-ws"); }); + it("rolls back a workspace created by a failed create-mode session acquisition", async () => { + const transport = new FakeTransport({ exists: false }); + transport.neverReady = true; + + const exit = await Effect.runPromiseExit( + Effect.scoped( + acquireSession({ + settings: { + workspace: "spike-ws", + create: { template: "docker", validate: false }, + readyTimeoutMs: 1, + defaultWorkingDirectory: "/home/coder", + transport, + }, + }), + ), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(transport.calls).toContain("create:spike-ws"); + // The created-but-never-ready workspace must not leak. + expect(transport.calls).toContain("destroy:spike-ws"); + }); + it("releases the session when the fiber is interrupted after acquisition", async () => { const transport = new FakeTransport({ exists: true }); // Wrap-mode acquisition makes no transport calls, so signal it explicitly. From 4e7f24a66adefcf3582e000388e8dba24f2499f3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 30 Aug 2026 11:55:40 +0000 Subject: [PATCH 5/6] fix(effect): use a named contract for the creation tracker Change-Id: I8c0153dc7019315ae73ad8a50246aafd4363d42c Signed-off-by: Thomas Kosiewski --- packages/effect/src/sandbox.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/effect/src/sandbox.ts b/packages/effect/src/sandbox.ts index 41815ce..4a632ad 100644 --- a/packages/effect/src/sandbox.ts +++ b/packages/effect/src/sandbox.ts @@ -116,6 +116,16 @@ export const acquireWorkspace = ( ); }; +/** Ownership signal for failed-acquisition rollback; see {@link trackCreation}. */ +interface CreationTracker { + /** The wrapped transport to pass into the acquisition call. */ + readonly transport: CoderTransport; + /** Whether a `create` call made through {@link transport} succeeded. */ + readonly createdByThisCall: () => boolean; + /** The workspace name that `create` call targeted, when it succeeded. */ + readonly createdName: () => string | undefined; +} + /** * Wrap a transport so a successful `create` call made *through this wrapper* * is observable. This is the ownership signal for failed-acquisition rollback: @@ -123,13 +133,7 @@ export const acquireWorkspace = ( * never mistake a concurrently created workspace for ours (in that race our * own `create` call fails and ownership stays false). */ -const trackCreation = ( - transport: CoderTransport, -): { - transport: CoderTransport; - createdByThisCall: () => boolean; - createdName: () => string | undefined; -} => { +const trackCreation = (transport: CoderTransport): CreationTracker => { let createdName: string | undefined; const tracking: CoderTransport = { exec: (options) => transport.exec(options), From a81f80fb02c61d0dc9f4c4c0eef77880d8c2d87f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 30 Aug 2026 12:05:38 +0000 Subject: [PATCH 6/6] docs: document the effect pr-title scope in agents guide Change-Id: Ia9c2a5c63ffe6795d2cd67c2d4375911ac9763fb Signed-off-by: Thomas Kosiewski --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c5dd3db..56f72f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,8 +43,8 @@ Gotchas: - **Conventional Commit PR titles are load-bearing.** PRs are squash-merged, the title becomes the commit on `main`, and release-please derives versions and changelogs from those commits. `pr-title.yml` validates titles (scope - `sandbox` / `agent` / `provider` or none; subject starts lowercase, no - trailing period). + `sandbox` / `agent` / `provider` / `effect` or none; subject starts + lowercase, no trailing period). - **Merge only through the merge queue** (squash). Auto-merge is disabled; if `gh pr merge` lacks queue support, use GraphQL `enqueuePullRequest`. - The single required status check is the aggregate `Required` job in