From 967a84861df721fbde294c8f80f322d2246537ab Mon Sep 17 00:00:00 2001 From: Kauan Guesser Date: Fri, 14 Aug 2026 11:08:34 -0300 Subject: [PATCH] feat(ai-sdk): add typed workspace conflict mapping --- .../compose-workspace-tool-conflicts.md | 12 ++ README.md | 18 +++ package.json | 2 +- pnpm-lock.yaml | 46 ++++---- src/ai-sdk/ai-sdk-workspace-tools.spec.ts | 111 ++++++++++++++++++ src/ai-sdk/ai-sdk-workspace-tools.ts | 105 +++++++++++++---- src/ai-sdk/index.ts | 2 + 7 files changed, 251 insertions(+), 45 deletions(-) create mode 100644 .changeset/compose-workspace-tool-conflicts.md diff --git a/.changeset/compose-workspace-tool-conflicts.md b/.changeset/compose-workspace-tool-conflicts.md new file mode 100644 index 0000000..31a3181 --- /dev/null +++ b/.changeset/compose-workspace-tool-conflicts.md @@ -0,0 +1,12 @@ +--- +'@nestm/storage': minor +--- + +Add a typed `mapCreateConflict` hook to the AI SDK workspace adapter so +applications can represent atomic create collisions as domain results without +mutating generated tools. Keep replace/ETag conflicts fail-closed and sanitize +mapper failures at the tool boundary. + +Mark workspace tools with optional inputs or a combined create/replace union as +non-strict for provider schema generation while retaining strict Zod runtime +validation. diff --git a/README.md b/README.md index 42743ac..4fc2543 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,24 @@ workspace capability remains the authorization boundary even when approval is disabled. The module's `AiSdkService.files()` API is the model provider's file upload facility and is unrelated to storage workspaces. +Atomic create collisions remain sanitized tool errors by default. Applications +that model an existing destination as a normal tool result can map that one +case while preserving replace/ETag conflicts as failures: + +```ts +const tools = createAiSdkWorkspaceTools({ + workspace, + mapCreateConflict: ({ path }) => ({ + kind: 'artifact-conflict' as const, + path, + status: 'already-exists' as const, + }), +}); +``` + +The mapper receives only the logical workspace path; provider errors, object +keys, and mount coordinates are never exposed. + This logical confinement is sufficient for a `ToolLoopAgent` whose only file capabilities are these tools. It cannot constrain a coding harness that already has shell, `node:fs`, or subprocess access. For Codex/Claude-style harnesses, diff --git a/package.json b/package.json index a5ca664..ce6365e 100644 --- a/package.json +++ b/package.json @@ -167,7 +167,7 @@ "@types/node": "26.1.2", "@types/supertest": "7.2.1", "@vitest/coverage-v8": "4.1.10", - "ai": "7.0.52", + "ai": "7.0.64", "fastify": "5.11.2", "oxlint": "1.77.0", "prettier": "3.9.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09561a2..623e03f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: dependencies: files-sdk: specifier: 2.2.3 - version: 2.2.3(@aws-sdk/client-s3@3.1103.0)(@aws-sdk/lib-storage@3.1103.0(@aws-sdk/client-s3@3.1103.0))(@aws-sdk/s3-presigned-post@3.1103.0)(@aws-sdk/s3-request-presigner@3.1103.0)(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(ai@7.0.52(zod@4.4.3))(fastify@5.11.2)(hono@4.13.0)(supports-color@7.2.0)(zod@4.4.3) + version: 2.2.3(@aws-sdk/client-s3@3.1103.0)(@aws-sdk/lib-storage@3.1103.0(@aws-sdk/client-s3@3.1103.0))(@aws-sdk/s3-presigned-post@3.1103.0)(@aws-sdk/s3-request-presigner@3.1103.0)(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(ai@7.0.64(zod@4.4.3))(fastify@5.11.2)(hono@4.13.0)(supports-color@7.2.0)(zod@4.4.3) devDependencies: '@aws-sdk/client-s3': specifier: 3.1103.0 @@ -55,8 +55,8 @@ importers: specifier: 4.1.10 version: 4.1.10(vitest@4.1.10) ai: - specifier: 7.0.52 - version: 7.0.52(zod@4.4.3) + specifier: 7.0.64 + version: 7.0.64(zod@4.4.3) fastify: specifier: 5.11.2 version: 5.11.2 @@ -90,20 +90,20 @@ importers: packages: - '@ai-sdk/gateway@4.0.41': - resolution: {integrity: sha512-DPkDmmA336kqmIp62on550/6Q6JHvldsnvN1SmkwU8QGrVt1PzFxqyKUn0C5EGGmSBpvF1ohZNtvWNl8gEVugA==} + '@ai-sdk/gateway@4.0.51': + resolution: {integrity: sha512-tQxWGUbg/O3A5EgekJo/C2byLVQ1r+vL6QdEVWmxUSnPhdCupcOeDbQO1tv9Um40VrdwYuSWlYzcbWvtlG5bOA==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@5.0.21': - resolution: {integrity: sha512-Q4z27c5vqKuXZN65QuF7FVm+uvm3qxEioiQ+8c3s5xXlhbE1Y/SLGBB4BuF+UWia4KaDu2HmNpdR1RGW02/eGg==} + '@ai-sdk/provider-utils@5.0.27': + resolution: {integrity: sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider@4.0.5': - resolution: {integrity: sha512-9WAerTaPU2jcVi8dh8x27tySEuLur1Kfg7Go74GuCmsC/2MDSvrTkrK8ZrXWjryEevKfcPPmO1enveE7eqlzoA==} + '@ai-sdk/provider@4.0.7': + resolution: {integrity: sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==} engines: {node: '>=22'} '@aws-sdk/checksums@3.1000.26': @@ -913,8 +913,8 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} - ai@7.0.52: - resolution: {integrity: sha512-sEgRnYA+ddJ1rJSz1uKBjkGbOXhXdon1nvno49fVk3tabnlf8v5SaceLBpPDMtmDuLKQFztc/YbRsoCQoPzKZw==} + ai@7.0.64: + resolution: {integrity: sha512-29Ufm56e53pRzIPueWzZiwJsgxmZStaBFpwEkVM89sqyIIax/pzDm+ez/ksJ/CcY9UCuYzvCQpE2zkFHICvS3w==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -2308,23 +2308,23 @@ packages: snapshots: - '@ai-sdk/gateway@4.0.41(zod@4.4.3)': + '@ai-sdk/gateway@4.0.51(zod@4.4.3)': dependencies: - '@ai-sdk/provider': 4.0.5 - '@ai-sdk/provider-utils': 5.0.21(zod@4.4.3) + '@ai-sdk/provider': 4.0.7 + '@ai-sdk/provider-utils': 5.0.27(zod@4.4.3) '@vercel/oidc': 3.2.0 zod: 4.4.3 - '@ai-sdk/provider-utils@5.0.21(zod@4.4.3)': + '@ai-sdk/provider-utils@5.0.27(zod@4.4.3)': dependencies: - '@ai-sdk/provider': 4.0.5 + '@ai-sdk/provider': 4.0.7 '@standard-schema/spec': 1.1.0 '@workflow/serde': 4.1.0 eventsource-parser: 3.1.0 undici: 7.29.0 zod: 4.4.3 - '@ai-sdk/provider@4.0.5': + '@ai-sdk/provider@4.0.7': dependencies: json-schema: 0.4.0 @@ -3207,11 +3207,11 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - ai@7.0.52(zod@4.4.3): + ai@7.0.64(zod@4.4.3): dependencies: - '@ai-sdk/gateway': 4.0.41(zod@4.4.3) - '@ai-sdk/provider': 4.0.5 - '@ai-sdk/provider-utils': 5.0.21(zod@4.4.3) + '@ai-sdk/gateway': 4.0.51(zod@4.4.3) + '@ai-sdk/provider': 4.0.7 + '@ai-sdk/provider-utils': 5.0.27(zod@4.4.3) zod: 4.4.3 ajv-formats@3.0.1(ajv@8.20.0): @@ -3574,7 +3574,7 @@ snapshots: transitivePeerDependencies: - supports-color - files-sdk@2.2.3(@aws-sdk/client-s3@3.1103.0)(@aws-sdk/lib-storage@3.1103.0(@aws-sdk/client-s3@3.1103.0))(@aws-sdk/s3-presigned-post@3.1103.0)(@aws-sdk/s3-request-presigner@3.1103.0)(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(ai@7.0.52(zod@4.4.3))(fastify@5.11.2)(hono@4.13.0)(supports-color@7.2.0)(zod@4.4.3): + files-sdk@2.2.3(@aws-sdk/client-s3@3.1103.0)(@aws-sdk/lib-storage@3.1103.0(@aws-sdk/client-s3@3.1103.0))(@aws-sdk/s3-presigned-post@3.1103.0)(@aws-sdk/s3-request-presigner@3.1103.0)(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(ai@7.0.64(zod@4.4.3))(fastify@5.11.2)(hono@4.13.0)(supports-color@7.2.0)(zod@4.4.3): dependencies: aws4fetch: 1.0.20 commander: 15.0.0 @@ -3587,7 +3587,7 @@ snapshots: '@aws-sdk/s3-request-presigner': 3.1103.0 '@modelcontextprotocol/sdk': 1.30.0(supports-color@7.2.0)(zod@4.4.3) '@nestjs/common': 12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) - ai: 7.0.52(zod@4.4.3) + ai: 7.0.64(zod@4.4.3) fastify: 5.11.2 hono: 4.13.0 zod: 4.4.3 diff --git a/src/ai-sdk/ai-sdk-workspace-tools.spec.ts b/src/ai-sdk/ai-sdk-workspace-tools.spec.ts index b7a8b4f..ecb6a0d 100644 --- a/src/ai-sdk/ai-sdk-workspace-tools.spec.ts +++ b/src/ai-sdk/ai-sdk-workspace-tools.spec.ts @@ -188,6 +188,7 @@ describe('createAiSdkWorkspaceTools', () => { 'workspace_stat', 'workspace_write_file', ]); + expect(viewTool(tools, 'workspace_write_file').strict).toBe(false); }); it('omits compound mutations until all enforcing permissions are present', () => { @@ -314,6 +315,7 @@ describe('createAiSdkWorkspaceTools', () => { false, ); expect(viewTool(tools, 'workspace_stat').strict).toBe(true); + expect(viewTool(tools, 'workspace_search').strict).toBe(false); }); it('describes cursor continuation with the bound query options', () => { @@ -331,6 +333,7 @@ describe('createAiSdkWorkspaceTools', () => { viewTool(tools, 'workspace_list').inputSchema.safeParse({ cursor }) .success, ).toBe(true); + expect(viewTool(tools, 'workspace_list').strict).toBe(false); expect( viewTool(tools, 'workspace_list').inputSchema.safeParse({ cursor: '', @@ -375,6 +378,7 @@ describe('createAiSdkWorkspaceTools', () => { createTools, 'workspace_write_file', ).inputSchema; + expect(viewTool(createTools, 'workspace_write_file').strict).toBe(true); expect( createSchema.safeParse({ path: 'new.txt', @@ -406,6 +410,7 @@ describe('createAiSdkWorkspaceTools', () => { replaceTools, 'workspace_write_file', ).inputSchema; + expect(viewTool(replaceTools, 'workspace_write_file').strict).toBe(true); expect( replaceSchema.safeParse({ path: 'old.txt', @@ -686,6 +691,112 @@ describe('createAiSdkWorkspaceTools', () => { }); }); + it('maps only atomic create conflicts through the typed application hook', async () => { + const fixture = createWorkspaceDouble(['create', 'replace']); + const conflict = new StorageWorkspaceError('private provider detail', { + code: StorageErrorCode.CONFLICT, + operation: 'writeFile', + path: 'existing.txt', + permanent: true, + }); + fixture.writeFile.mockRejectedValue(conflict); + const mappedPaths: string[] = []; + const tools = createAiSdkWorkspaceTools({ + workspace: fixture.workspace, + mapCreateConflict: ({ path }) => { + mappedPaths.push(path); + return { kind: 'already-exists' as const, path }; + }, + }); + + await expect( + executeTool(tools, 'workspace_write_file', { + path: 'existing.txt', + content: 'new', + mode: 'create', + }), + ).resolves.toEqual({ kind: 'already-exists', path: 'existing.txt' }); + expect(mappedPaths).toEqual(['existing.txt']); + + await expect( + executeTool(tools, 'workspace_write_file', { + path: 'existing.txt', + content: 'new', + mode: 'replace', + etag: 'stale-etag', + }), + ).rejects.toMatchObject({ code: StorageErrorCode.CONFLICT }); + expect(mappedPaths).toEqual(['existing.txt']); + + fixture.writeFile.mockRejectedValueOnce( + new StorageWorkspaceError('private provider detail', { + code: StorageErrorCode.PROVIDER, + operation: 'writeFile', + path: 'new.txt', + }), + ); + await expect( + executeTool(tools, 'workspace_write_file', { + path: 'new.txt', + content: 'new', + mode: 'create', + }), + ).rejects.toMatchObject({ code: StorageErrorCode.PROVIDER }); + expect(mappedPaths).toEqual(['existing.txt']); + }); + + it('keeps create conflicts as errors when no mapper is configured', async () => { + const fixture = createWorkspaceDouble(['create']); + fixture.writeFile.mockRejectedValue( + new StorageWorkspaceError('private provider detail', { + code: StorageErrorCode.CONFLICT, + operation: 'writeFile', + path: 'existing.txt', + }), + ); + const tools = createAiSdkWorkspaceTools({ workspace: fixture.workspace }); + + await expect( + executeTool(tools, 'workspace_write_file', { + path: 'existing.txt', + content: 'new', + mode: 'create', + }), + ).rejects.toMatchObject({ + code: StorageErrorCode.CONFLICT, + message: + 'The operation conflicts with current workspace state. Refresh metadata and retry with the current ETag or a new destination.', + }); + }); + + it('sanitizes failures thrown by a create-conflict mapper', async () => { + const fixture = createWorkspaceDouble(['create']); + fixture.writeFile.mockRejectedValue( + new StorageWorkspaceError('private provider detail', { + code: StorageErrorCode.CONFLICT, + operation: 'writeFile', + path: 'existing.txt', + }), + ); + const tools = createAiSdkWorkspaceTools({ + workspace: fixture.workspace, + mapCreateConflict: () => { + throw new Error('private application detail'); + }, + }); + + await expect( + executeTool(tools, 'workspace_write_file', { + path: 'existing.txt', + content: 'new', + mode: 'create', + }), + ).rejects.toMatchObject({ + code: StorageErrorCode.PROVIDER, + message: 'The workspace operation failed.', + }); + }); + it('sanitizes workspace and unknown failures without retaining their cause', async () => { const fixture = createWorkspaceDouble(['read']); fixture.stat.mockRejectedValueOnce( diff --git a/src/ai-sdk/ai-sdk-workspace-tools.ts b/src/ai-sdk/ai-sdk-workspace-tools.ts index 36af38f..99bf20d 100644 --- a/src/ai-sdk/ai-sdk-workspace-tools.ts +++ b/src/ai-sdk/ai-sdk-workspace-tools.ts @@ -1,4 +1,4 @@ -import { tool, type ToolSet } from 'ai'; +import { tool, type JSONValue, type ToolSet } from 'ai'; import { z } from 'zod'; import { @@ -45,7 +45,18 @@ export type AiSdkWorkspaceMutationToolName = Extract< export type AiSdkWorkspaceApprovalConfig = boolean | Partial>; -export interface CreateAiSdkWorkspaceToolsOptions { +export interface AiSdkWorkspaceCreateConflict { + /** The logical destination inside the mounted workspace. */ + readonly path: string; +} + +export type AiSdkWorkspaceCreateConflictMapper = ( + conflict: AiSdkWorkspaceCreateConflict, +) => PromiseLike | Result; + +export interface CreateAiSdkWorkspaceToolsOptions< + CreateConflictResult extends JSONValue = never, +> { /** The already-mounted, policy-enforcing workspace exposed to the tools. */ workspace: StorageWorkspace; /** @@ -55,6 +66,12 @@ export interface CreateAiSdkWorkspaceToolsOptions { maxReadBytes?: number; /** Mutation tools require approval by default. */ requireApproval?: AiSdkWorkspaceApprovalConfig; + /** + * Maps an atomic create collision to an application result. When omitted, + * the collision remains an AiSdkWorkspaceToolError like every other storage + * failure. Replace conflicts are never mapped by this hook. + */ + mapCreateConflict?: AiSdkWorkspaceCreateConflictMapper; } export type AiSdkWorkspaceToolErrorCode = StorageErrorCodeValue; @@ -284,6 +301,35 @@ async function executeSafely( } } +async function executeCreateAware< + Result, + CreateConflictResult extends JSONValue, +>( + signal: AbortSignal | undefined, + input: { mode: 'create' | 'replace'; path: string }, + operation: () => Promise, + mapCreateConflict: + AiSdkWorkspaceCreateConflictMapper | undefined, +): Promise { + try { + return await operation(); + } catch (error) { + const safeError = sanitizeToolError(error, signal); + if ( + input.mode === 'create' && + safeError.code === StorageErrorCode.CONFLICT && + mapCreateConflict !== undefined + ) { + try { + return await mapCreateConflict({ path: input.path }); + } catch { + throw new AiSdkWorkspaceToolError(StorageErrorCode.PROVIDER); + } + } + throw safeError; + } +} + function serializeFile(file: StorageWorkspaceFile): AiSdkWorkspaceFileResult { if (file.etag !== undefined && !isCanonicalStorageEtag(file.etag)) { throw new Error('Workspace returned a malformed ETag.'); @@ -332,11 +378,14 @@ function serializePage(page: { * The workspace remains the enforcing boundary if a retained tool reference * is invoked after further narrowing. */ -export function createAiSdkWorkspaceTools({ +export function createAiSdkWorkspaceTools< + CreateConflictResult extends JSONValue = never, +>({ workspace, maxReadBytes: requestedMaxReadBytes, requireApproval = true, -}: CreateAiSdkWorkspaceToolsOptions): ToolSet { + mapCreateConflict, +}: CreateAiSdkWorkspaceToolsOptions): ToolSet { const maxReadBytes = resolveReadLimit(workspace, requestedMaxReadBytes); const tools: ToolSet = {}; const pathSchema = logicalPath('File path', workspace.limits.maxPathBytes); @@ -350,7 +399,9 @@ export function createAiSdkWorkspaceTools({ tools.workspace_list = tool({ description: 'List files and directories inside the mounted workspace. Omit directory to list the workspace root.', - strict: true, + // Optional pagination and directory inputs are not compatible with + // OpenAI strict function schemas, which require every property. + strict: false, inputSchema: z .object({ directory: directorySchema.optional(), @@ -421,7 +472,8 @@ export function createAiSdkWorkspaceTools({ tools.workspace_search = tool({ description: 'Search logical paths inside the mounted workspace using a bounded glob, substring, or exact match. Omit directory to search from the workspace root.', - strict: true, + // Search intentionally has optional filters and cursor inputs. + strict: false, inputSchema: z .object({ query: searchQuery(workspace.limits.maxPathBytes), @@ -496,30 +548,41 @@ export function createAiSdkWorkspaceTools({ ? createSchema : replaceSchema; - tools.workspace_write_file = tool({ + tools.workspace_write_file = tool< + z.infer, + unknown, + Record + >({ description: canCreate && canReplace ? 'Create a new UTF-8 text file or replace an existing file inside the mounted workspace. Create fails if the destination exists; replace requires its current ETag.' : canCreate ? 'Create a new UTF-8 text file inside the mounted workspace. The operation fails if the destination already exists.' : 'Replace an existing UTF-8 text file inside the mounted workspace using its current ETag.', - strict: true, + // The combined create/replace schema is a discriminated union. OpenAI + // strict function tools reject its root-level oneOf, while the runtime + // Zod schema continues to validate every tool call in non-strict mode. + strict: !(canCreate && canReplace), inputSchema, needsApproval: resolveApproval('workspace_write_file', requireApproval), execute: (input, { abortSignal }) => - executeSafely(abortSignal, async () => - serializeFile( - input.mode === 'create' - ? await workspace.writeFile(input.path, input.content, { - mode: 'create', - ...operationOptions(abortSignal), - }) - : await workspace.writeFile(input.path, input.content, { - mode: 'replace', - etag: input.etag, - ...operationOptions(abortSignal), - }), - ), + executeCreateAware( + abortSignal, + input, + async () => + serializeFile( + input.mode === 'create' + ? await workspace.writeFile(input.path, input.content, { + mode: 'create', + ...operationOptions(abortSignal), + }) + : await workspace.writeFile(input.path, input.content, { + mode: 'replace', + etag: input.etag, + ...operationOptions(abortSignal), + }), + ), + mapCreateConflict, ), }); } diff --git a/src/ai-sdk/index.ts b/src/ai-sdk/index.ts index 5a5f667..2ac17d0 100644 --- a/src/ai-sdk/index.ts +++ b/src/ai-sdk/index.ts @@ -4,6 +4,8 @@ export { createAiSdkWorkspaceTools, isAiSdkWorkspaceMutationToolName, type AiSdkWorkspaceApprovalConfig, + type AiSdkWorkspaceCreateConflict, + type AiSdkWorkspaceCreateConflictMapper, type AiSdkWorkspaceDirectoryResult, type AiSdkWorkspaceEntryResult, type AiSdkWorkspaceFileResult,