diff --git a/.gitattributes b/.gitattributes index c3aff4f4..81d654ad 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ api/** text eol=lf +bin/** text eol=lf +packages/*/bin/** text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa3c560d..6468112b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,13 +37,13 @@ jobs: # this test always failed once it actually got a chance to run. - run: npm run build # The dual-entry build's DTS pass (worker + executor entries) exceeds - # the default heap, like the peers job's build below. + # the default heap, like the compat job's build below. env: NODE_OPTIONS: --max-old-space-size=12288 # API surface gate: every exports entry has a checked-in api/*.api.md # report; a public-API change fails here until `npm run api:update` is run # and the diff committed. Runs only in the main job so the report reflects - # the default peers, not the `peers` matrix's ai/zod pairings. + # the default peers, not the compat job's ai/zod pairing. - run: npm run api:check # packages/create-lousho-agent's own tests execFileSync its bin/cli.js, # which requires ITS dist/index.js to exist too - build it before the @@ -73,36 +73,27 @@ jobs: # Dead code, duplication and complexity gate (config: .fallowrc.json). # Bare `fallow` fails on any finding. Runs after the coverage step because # its CRAP scores read coverage/coverage-final.json. - - run: npm run fallow - # Every `ai` and zod pairing the peer ranges accept, installed for real on top of the - # default install (`npm ci`; `--no-save` leaves the lockfile alone). The default dev install is - # `ai` 4 with zod 3 and runs in `ci`. `ollama-ai-provider-v2` peers on zod 4, so only the zod 4 - # entries install it; ollamaV2.contract.test.ts runs against the real package in those two. - # `flags` for those two: ollama-ai-provider-v2 is an optional peer of this package, which npm does not - # install by name unless `--save-prod` (in-memory only with --no-save) makes it a dependency, and the dev - # install's ollama-ai-provider (v1, zod 3 peer) conflicts with zod 4, hence `--legacy-peer-deps`. - # The v4-only tests are gated by itOnAiV4/describeOnAiV4; Worker bundle tests run on all. - peers: - name: peers (${{ matrix.name }}) + - run: npm run fallow || (npx fallow health; npx fallow dupes; exit 1) + # What `npm pack` produces must install and work in a fresh project: builds, packs the SDK and + # create-lousho-agent, `npm publish --dry-run` (never a real publish), installs the tarballs plus + # peers in a fresh project, loads every export in ESM and CJS, a mock-model turn, the bin and tsc. + - run: npm run pack-smoke + env: + NODE_OPTIONS: --max-old-space-size=12288 + # Hello-world bundle and tarball unpacked size against scripts/size-budget.json (after pack-smoke's builds). + - run: npm run size-check + # Source and tests also type-check against zod 4 (last: it changes node_modules). + - run: npm install --no-save zod@4 + - run: npx tsc --noEmit + - run: npm run typecheck:tests + # The newest peers the ranges accept (`ai` 7, `@ai-sdk/*` 4, `ollama-ai-provider-v2` 4, zod 4), installed + # on top of the default `ai` 4 / zod 3 install (`--no-save` leaves the lockfile alone; `--save-prod + # --legacy-peer-deps` because ollama-ai-provider-v2 is an optional peer and the dev install's v1 conflicts + # with zod 4). Then the sandbox egress, credential broker and docker deploy image against the runner's + # real Docker Engine (`*.docker.test.ts`, which fail instead of skipping with LOUSHO_DOCKER_TESTS=1). + compat: runs-on: ubuntu-latest - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - include: - - name: ai4-zod4 - install: zod@4 - typecheck-tests: true - - name: ai6-zod3 - install: ai@6 @ai-sdk/openai@3 @ai-sdk/anthropic@3 - - name: ai6-zod4 - install: ai@6 @ai-sdk/openai@3 @ai-sdk/anthropic@3 ollama-ai-provider-v2@3 zod@4 - flags: --save-prod --legacy-peer-deps - - name: ai7-zod3 - install: ai@7 @ai-sdk/openai@4 @ai-sdk/anthropic@4 - - name: ai7-zod4 - install: ai@7 @ai-sdk/openai@4 @ai-sdk/anthropic@4 ollama-ai-provider-v2@4 zod@4 - flags: --save-prod --legacy-peer-deps + timeout-minutes: 25 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -110,36 +101,15 @@ jobs: node-version: '22.19.0' cache: 'npm' - run: npm ci - - run: npm install --no-save ${{ matrix.flags }} ${{ matrix.install }} + - run: npm install --no-save --save-prod --legacy-peer-deps ai@7 @ai-sdk/openai@4 @ai-sdk/anthropic@4 ollama-ai-provider-v2@4 zod@4 - run: npx tsc --noEmit - # The tests are written against `ai` 4's types (v4-shaped Tool, MockLanguageModelV1), so they only - # type-check there; ai4-zod4 is the zod 4 entry (it was the old typecheck-zod4 job). - - run: npm run typecheck:tests - if: matrix.typecheck-tests - run: npm run test:types - # The DTS build of the ai7-zod4 install runs out of the default heap (seen on #322, #335). - run: npm run build env: NODE_OPTIONS: --max-old-space-size=12288 - run: npm run build --workspace=packages/create-lousho-agent - run: npx vitest run - # What `npm pack` produces must install and work in a fresh project. The script - # builds, packs the SDK and create-lousho-agent, runs `npm publish --dry-run` (never a real - # publish), installs the tarballs plus peers from the registry, and loads every export in - # ESM and CJS, runs a mock-model turn, the lousho bin and tsc (bundler and node16). - pack-smoke: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '22.19.0' - cache: 'npm' - - run: npm ci - - run: npm run pack-smoke - # pack-smoke runs `npm run build` itself, which needs the same heap. + - run: docker pull curlimages/curl:8.10.1 && docker pull node:20-alpine && docker pull node:22-slim + - run: npm run test:docker env: - NODE_OPTIONS: --max-old-space-size=12288 - # Hello-world bundle and tarball unpacked size against scripts/size-budget.json. - # pack-smoke above leaves the SDK and Agent Forge builds in place, so the tarball figure is the real one. - - run: npm run size-check + LOUSHO_DOCKER_TESTS: '1' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 93cb0330..00000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Docker Engine -# The sandbox's network egress and credential broker, and the docker -# deploy image, against a real Docker Engine (GitHub's ubuntu-latest runner, -# rootful). A separate workflow so ci.yml stays fast; it runs on pull requests -# that touch the code under test, on demand, and weekly (Engine updates on the -# runner image are caught even when nothing here changes). -on: - pull_request: - branches: [main] - paths: - - 'src/security/**' - - 'src/tools/workspace/**' - - 'src/deploy/adapters/docker*' - - 'vitest.docker.config.ts' - - '.github/workflows/docker.yml' - workflow_dispatch: - schedule: - - cron: '17 4 * * 1' -jobs: - docker-engine: - runs-on: ubuntu-latest - timeout-minutes: 15 - env: - # Fail instead of skipping when no daemon answers (src/security/docker.testkit.ts). - LOUSHO_DOCKER_TESTS: '1' - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '22.19.0' - cache: 'npm' - - run: npm ci - # For the log: Engine version, and rootless or not (egress needs rootful Engine 25.0.5+). - - run: docker version - - run: docker info - # Pre-pulled so the first test does not spend its timeout on a pull. - - run: docker pull curlimages/curl:8.10.1 - - run: docker pull node:20-alpine - - run: docker pull node:22-slim - - run: npm run build - env: - NODE_OPTIONS: --max-old-space-size=12288 - - run: npm run test:docker - - name: Diagnostics (on failure) - if: failure() - run: | - sudo iptables -S INPUT || true - sudo iptables -S FORWARD || true - sudo iptables -S DOCKER-USER || true - docker network ls - docker ps -a diff --git a/.github/workflows/e2e-studio.yml b/.github/workflows/e2e-studio.yml deleted file mode 100644 index aab8d2c7..00000000 --- a/.github/workflows/e2e-studio.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: E2E (Agent Forge studio) -on: - pull_request: - branches: [main] - paths: - - 'apps/agent-forge/**' - - 'src/cli/studio.ts' - - 'src/cli/studio.test.ts' - - 'bin/lousho.js' - - 'package.json' - - 'package-lock.json' - - '.github/workflows/e2e-studio.yml' -jobs: - e2e-studio: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - - run: npm ci - # The bundled prod server (apps/agent-forge/dist-server/index.cjs) - # leaves `@lousho/build-ai-agent` external (see server/tsup.config.ts's - # doc comment) and resolves it at runtime via the workspace symlink - - # which points at the ROOT package's `dist/` (package.json - # main/exports), not its `src/`. Verified locally: without this build - # step, `node dist-server/index.cjs` (what playwright.config.ts's - # `webServer` runs) fails immediately with `Cannot find module - # '.../node_modules/@lousho/build-ai-agent/dist/index.js'`. - - run: npm run build - env: - NODE_OPTIONS: --max-old-space-size=12288 - # Playwright's browser binaries aren't npm dependencies - install the - # Chromium build (+ OS-level deps) the E2E spec drives separately. - - run: npx playwright install --with-deps chromium - working-directory: apps/agent-forge - # `npm run test:e2e:studio` builds apps/agent-forge's own client+server - # (`build:studio`) on top of the root `dist/` from the step above, then - # runs Playwright against the real built prod server. - # - # This job is intentionally NOT required for merge yet (see - # `continue-on-error` below): full build + browser install make it - # noticeably slower than the main matrix, and it's new - giving it a - # few real PR cycles to prove non-flaky before making it blocking. - - run: npm run test:e2e:studio - continue-on-error: true diff --git a/.github/workflows/registry-smoke.yml b/.github/workflows/registry-smoke.yml deleted file mode 100644 index 4e887003..00000000 --- a/.github/workflows/registry-smoke.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Registry smoke -# Tests the packages as published on npm (not the checkout's build): scaffold with -# `npm create lousho-agent`, the project's own tests, every export in ESM and CJS, -# `lousho doctor`, and `lousho studio`. No secrets: the --live step is never run here. -on: - schedule: - - cron: '0 6 * * 1' # Mondays 06:00 UTC - workflow_dispatch: - inputs: - version: - description: 'SDK version or dist-tag to test (default: latest)' - required: false - default: 'latest' -jobs: - registry-smoke: - name: registry-smoke (Node ${{ matrix.node-version }}, ${{ matrix.os }}) - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest] - node-version: ['22.19.0', '26'] - include: - - os: windows-latest - node-version: '22.19.0' - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - run: npm ci - - run: npm run registry-smoke -- --version "${{ inputs.version || 'latest' }}" - shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index a3a1c3c0..42914b29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ What is on `main` and not yet on npm. Entries marked **Behavior change** can break working code; [Upgrading](docs/upgrading.md) lists each one with its opt-out. +### Changed + +- CI is one workflow with two jobs: `ci` (type checks, lint, build, tests with coverage, docs, API and fallow gates, pack-smoke, size check, and a zod 4 type check) and `compat` (the newest peers, `ai` 7 with zod 4, plus the real-Docker sandbox tests). The separate Docker Engine, Agent Forge E2E and weekly registry-smoke workflows and the `ai` 6 / zod 3 matrix entries are gone; `npm run registry-smoke` and `npm run test:e2e:studio` still run locally. When the fallow gate fails, CI now prints its full health and duplication reports. + ### Added - `fromAiSdkTools(record)` converts Vercel AI SDK tools (`tool({ ... })` or plain `{ description, inputSchema | parameters, execute, needsApproval? }` objects) into Lousho tools, keyed by name: zod and Standard Schemas are kept, `jsonSchema()` wrappers and plain JSON Schemas are converted (their own `validate` still runs), `needsApproval` (boolean or function) pauses the run for approval, and `execute` gets the call id and the run's abort signal. It imports nothing from `ai`, so tools built on `ai` 4 to 7 work with any installed version. New guide: [Migrating from the AI SDK](docs/migrating-from-ai-sdk.md). - `lousho eval` without vitest installed now says it runs `*.eval.ts` files through vitest, the directory it searched from, and the install command; [Evals](docs/evals.md) states the requirement up front. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6426b039..46dcd854 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,7 +54,7 @@ By participating in this project, you agree to maintain a respectful and inclusi npm test # Tests npm run build # Build ``` - Changes to the Docker sandbox, its network egress, the credential broker or the docker deploy adapter also have real-daemon tests, `npm run test:docker` (`*.docker.test.ts`, not part of `npm test`). They skip without a Docker daemon (the egress cases also skip on Docker Desktop and rootless Docker, where egress is refused by design), so they run in the "Docker Engine" CI workflow on Linux; set `LOUSHO_DOCKER_TESTS=1` to make a missing daemon fail the run instead. + Changes to the Docker sandbox, its network egress, the credential broker or the docker deploy adapter also have real-daemon tests, `npm run test:docker` (`*.docker.test.ts`, not part of `npm test`). They skip without a Docker daemon (the egress cases also skip on Docker Desktop and rootless Docker, where egress is refused by design), so they run in CI's `compat` job on Linux; set `LOUSHO_DOCKER_TESTS=1` to make a missing daemon fail the run instead. 4. Commit your changes with a descriptive commit message @@ -78,7 +78,7 @@ By participating in this project, you agree to maintain a respectful and inclusi - Ensure all existing tests pass - Aim for good test coverage - Tests are located alongside source files with `.test.ts` extension -- `npm run pack-smoke` installs the tarball built from your checkout into a fresh project and exercises it. `npm run registry-smoke` does the same for what is published on npm: `npm create lousho-agent`, the generated project's tests and type check, `lousho doctor`, every export in ESM and CJS, and `lousho studio`. Run it right after a publish (`-- --version ` picks the version; `-- --live` adds one real OpenRouter turn and needs `OPENROUTER_API_KEY`). A weekly CI job runs it on Node 22 and 26 and on Windows. +- `npm run pack-smoke` installs the tarball built from your checkout into a fresh project and exercises it. `npm run registry-smoke` does the same for what is published on npm: `npm create lousho-agent`, the generated project's tests and type check, `lousho doctor`, every export in ESM and CJS, and `lousho studio`. Run it right after a publish (`-- --version ` picks the version; `-- --live` adds one real OpenRouter turn and needs `OPENROUTER_API_KEY`). ### Documentation diff --git a/bin/lousho.js b/bin/lousho.js index 3a1f125e..78320490 100755 --- a/bin/lousho.js +++ b/bin/lousho.js @@ -84,17 +84,27 @@ const BYTE_ORDER_MARK = 0xfeff; * silently missing. */ function loadDotEnv() { + const text = readDotEnv(); + if (text === undefined) return; + for (const [name, value] of Object.entries(parseEnv(withoutByteOrderMark(text)))) { + if (process.env[name] === undefined) process.env[name] = value; + } +} + +/** ./.env's text, or undefined when there is none (any other read error is reported). */ +function readDotEnv() { try { - const text = fs.readFileSync(path.resolve('.env'), 'utf8'); - const vars = parseEnv(text.charCodeAt(0) === BYTE_ORDER_MARK ? text.slice(1) : text); - for (const [name, value] of Object.entries(vars)) { - if (process.env[name] === undefined) process.env[name] = value; - } + return fs.readFileSync(path.resolve('.env'), 'utf8'); } catch (error) { if (error.code !== 'ENOENT') console.error(`lousho: could not load .env: ${error.message}`); + return undefined; } } +function withoutByteOrderMark(text) { + return text.charCodeAt(0) === BYTE_ORDER_MARK ? text.slice(1) : text; +} + // `lousho --version` / `-v` / `version` print the package version (kept in sync with VERSION by scripts/sync-version.mjs). function runVersion(rest) { if (rest.length > 0) return usageError(`'version' takes no arguments; got '${rest[0]}'.`); diff --git a/docs/workspace-tools.md b/docs/workspace-tools.md index 733d48a7..8071473b 100644 --- a/docs/workspace-tools.md +++ b/docs/workspace-tools.md @@ -502,8 +502,8 @@ What this enforces, and where: the allowlist check. Raw IP connections have no route. Non-HTTP protocols (SSH, raw TCP, UDP) are blocked, because only the proxy is reachable, and `CONNECT` tunnels are checked against the allowlist like any request. - This is tested against a real daemon: the Docker Engine CI workflow - (`.github/workflows/docker.yml`, `npm run test:docker`) runs on GitHub's + This is tested against a real daemon: CI's `compat` job + (`npm run test:docker`) runs on GitHub's `ubuntu-latest` runner (rootful Docker Engine 28.0.4 on Ubuntu 24.04 when it was added) and checks that an allowed host answers through the broker, that other hosts get a 403, that bypassing the proxy or dialling a raw IP diff --git a/llms-full.txt b/llms-full.txt index 96ebf2bd..1d77f7c9 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -20495,8 +20495,8 @@ What this enforces, and where: the allowlist check. Raw IP connections have no route. Non-HTTP protocols (SSH, raw TCP, UDP) are blocked, because only the proxy is reachable, and `CONNECT` tunnels are checked against the allowlist like any request. - This is tested against a real daemon: the Docker Engine CI workflow - (`.github/workflows/docker.yml`, `npm run test:docker`) runs on GitHub's + This is tested against a real daemon: CI's `compat` job + (`npm run test:docker`) runs on GitHub's `ubuntu-latest` runner (rootful Docker Engine 28.0.4 on Ubuntu 24.04 when it was added) and checks that an allowed host answers through the broker, that other hosts get a 403, that bypassing the proxy or dialling a raw IP diff --git a/src/cli/doctor.test.ts b/src/cli/doctor.test.ts index 39ab27be..ac06e317 100644 --- a/src/cli/doctor.test.ts +++ b/src/cli/doctor.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import * as os from 'node:os'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { buildEnvironment, parseDoctorArgs, runDoctorCommand } from './doctor'; +import { buildEnvironment, parseDoctorArgs, runDoctorCommand, toolNames } from './doctor'; import { satisfiesRange } from './versionRange'; describe('satisfiesRange', () => { @@ -99,3 +99,12 @@ describe('runDoctorCommand', () => { expect(String(lines[0])).toContain('lousho doctor'); }); }); + +describe('toolNames', () => { + it('collects names from tools, nested arrays and records; none for a function or a nameless value', () => { + expect(toolNames([{ name: 'a' }, [{ name: 'b' }], { group: { name: 'c' }, other: [{ name: 'd' }] }])).toEqual(['a', 'b', 'c', 'd']); + expect(toolNames(() => [])).toEqual([]); + expect(toolNames(null)).toEqual([]); + expect(toolNames({ name: 42 })).toEqual([]); + }); +}); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index f82cd233..f81aa9b8 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -112,7 +112,7 @@ function readSdkManifest(): SdkManifest { } /** The names of a createAgent() `tools` option: tools, arrays and records of them (nested); none for a function of the run. */ -function toolNames(tools: unknown): string[] { +export function toolNames(tools: unknown): string[] { if (Array.isArray(tools)) return tools.flatMap(toolNames); if (typeof tools !== 'object' || tools === null) return []; const { name } = tools as { name?: unknown }; diff --git a/src/execution/generateStep.ts b/src/execution/generateStep.ts index 6b3e3c82..a1af7948 100644 --- a/src/execution/generateStep.ts +++ b/src/execution/generateStep.ts @@ -160,8 +160,7 @@ export async function prepareGenerateRequest( // The tools of this call, from the transcript as it is now (deferred tools load through `tool_search`). const deferral = deferralOf(options); const sent = visibleTools(tools, messages, deferral); - // Tools the model may call on this call: function tools not disabled by `toolChoice: 'none'`, or hosted ones. - const offersTools = !answerOnly && ((sent.length > 0 && settings.toolChoice !== 'none') || Boolean(hostedTools?.length)); + const offersTools = !answerOnly && callOffersTools(sent, settings.toolChoice, hostedTools); const responseFormat = options.output ? outputResponseFormat(options.output, offersTools) : undefined; const generateRequest: GenerateOptions = { @@ -202,6 +201,11 @@ export async function prepareGenerateRequest( return generateRequest; } +/** Tools the model may call on this call: function tools not disabled by `toolChoice: 'none'`, or hosted ones. */ +function callOffersTools(sent: ToolDefinition[], toolChoice: GenerateOptions['toolChoice'], hostedTools: readonly unknown[] | undefined): boolean { + return (sent.length > 0 && toolChoice !== 'none') || Boolean(hostedTools?.length); +} + /** * `messages` with every system message after the first non-system one (a * handoff's routing note, or one a caller put in the history) appended, in diff --git a/src/providers/AnthropicProvider.ts b/src/providers/AnthropicProvider.ts index b16b0032..f8dbc8ae 100644 --- a/src/providers/AnthropicProvider.ts +++ b/src/providers/AnthropicProvider.ts @@ -10,7 +10,7 @@ import { LanguageModel } from 'ai'; import { AiSdkProvider, AiSdkProviderConfig } from './aiSdkProvider'; -import { aiMajorOf } from './aiSdkCompat'; +import { aiMajorOf, documentFileTypes } from './aiSdkCompat'; import { lazyValue, loadOptionalPeer } from './optionalPeer'; import { mappedHostedOptions, type HostedOptionMapping } from './hostedToolMapping'; import { hostedToolUnsupported, type HostedTool, type HostedToolType } from '../tools/hosted'; @@ -78,7 +78,7 @@ export class AnthropicProvider extends AiSdkProvider { /** PDF file parts go to the model on ai 6 and 7; older peers have no file parts. */ protected fileMediaTypes(): readonly string[] { - return aiMajorOf(this.ai) >= 6 ? ['application/pdf', 'text/plain'] : []; + return documentFileTypes(this.ai); } protected async createModel(modelId: string): Promise { diff --git a/src/providers/GoogleProvider.ts b/src/providers/GoogleProvider.ts index 289271e5..e9570c62 100644 --- a/src/providers/GoogleProvider.ts +++ b/src/providers/GoogleProvider.ts @@ -5,7 +5,7 @@ import { LanguageModel } from 'ai'; import { AiSdkProvider, AiSdkProviderConfig } from './aiSdkProvider'; -import { aiMajorOf } from './aiSdkCompat'; +import { aiMajorOf, documentFileTypes } from './aiSdkCompat'; import { lazyValue, loadOptionalPeer } from './optionalPeer'; export interface GoogleProviderConfig extends AiSdkProviderConfig { @@ -29,7 +29,7 @@ export class GoogleProvider extends AiSdkProvider { /** PDF and plain-text file parts go to the model on ai 6 and 7; older peers have no file parts. */ protected fileMediaTypes(): readonly string[] { - return aiMajorOf(this.ai) >= 6 ? ['application/pdf', 'text/plain'] : []; + return documentFileTypes(this.ai); } protected async createModel(modelId: string): Promise { diff --git a/src/providers/aiSdkCompat.ts b/src/providers/aiSdkCompat.ts index 095ab0b3..a10b4b19 100644 --- a/src/providers/aiSdkCompat.ts +++ b/src/providers/aiSdkCompat.ts @@ -79,6 +79,11 @@ export function aiMajorOf(ai: AiSdkModule): AiMajor { return 'registerTelemetry' in ai ? 7 : 6; } +/** The document file types (PDF, plain text) the `@ai-sdk/*` peers send as `file` parts: ai 6 and 7 only. */ +export function documentFileTypes(ai: AiSdkModule): readonly string[] { + return aiMajorOf(ai) >= 6 ? ['application/pdf', 'text/plain'] : []; +} + /** A tool call as `ai` v4 (`args`) or v5+ (`input`) returns it. */ interface AiSdkToolCall { toolCallId: string; diff --git a/src/tools/mcp/loadMcpTools.test.ts b/src/tools/mcp/loadMcpTools.test.ts index b651729a..8bf8cf86 100644 --- a/src/tools/mcp/loadMcpTools.test.ts +++ b/src/tools/mcp/loadMcpTools.test.ts @@ -21,7 +21,7 @@ describe('loadMcpTools', () => { const descriptor = await load(async () => { throw cause; }); - const error = await descriptor.tool.execute!({}, {} as ToolExecutionOptions).catch((e: unknown) => e); + const error = await Promise.resolve(descriptor.tool.execute!({}, {} as ToolExecutionOptions)).catch((e: unknown) => e); expect(error).toBeInstanceOf(SDKError); expect((error as SDKError).code).toBe('LOUSHO_MCP_CALL_FAILED'); expect((error as SDKError).message).toContain("'srv__ping'"); diff --git a/src/tools/mcp/schema.ts b/src/tools/mcp/schema.ts index 7eac4c81..3db36b45 100644 --- a/src/tools/mcp/schema.ts +++ b/src/tools/mcp/schema.ts @@ -83,8 +83,8 @@ function convertKeywords(schema: JsonSchema, ctx: Context): ZodTypeAny { */ function applyNullable(zodType: ZodTypeAny, schema: JsonSchema): ZodTypeAny { if (schema.nullable !== true) return zodType; - if (zodType instanceof z.ZodDefault) return zodType.removeDefault().nullable().default(schema.default); - return zodType.nullable(); + if (zodType instanceof z.ZodDefault) return z.nullable(zodType.removeDefault()).default(schema.default); + return z.nullable(zodType); } function convertBase(schema: JsonSchema, ctx: Context): ZodTypeAny { diff --git a/src/tools/workspace/checkpoints.test.ts b/src/tools/workspace/checkpoints.test.ts index 684a7b39..58b04d19 100644 --- a/src/tools/workspace/checkpoints.test.ts +++ b/src/tools/workspace/checkpoints.test.ts @@ -211,6 +211,10 @@ describe('WorkspaceCheckpoints details', () => { toolCallId: 'c', sessionId, messages: Array.from({ length: users }, () => ({ role: 'user' as const, content: 'x' })), + getToken: (): Promise => Promise.reject(new Error('checkpoints.test.ts has no token store')), + requireAuth: (): never => { + throw new Error('checkpoints.test.ts has no token store'); + }, }); it('restores the mode of a file deleted outside the agent (force)', async () => {