@nestm/storage 0.1.0-alpha.8 → 0.1.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @nestm/storage
2
2
 
3
+ ## 0.1.0-alpha.9
4
+
5
+ ### Minor Changes
6
+
7
+ - aec25d6: Add explicit last-write-wins workspace write, copy, and unconditional-delete
8
+ operations that traverse the ordinary Files SDK plugin, hook, and receipt
9
+ pipeline while retaining the existing native conditional create, replace,
10
+ copy, move, and delete variants. Unconditional delete requires both `write` and
11
+ `delete`; move remains conditional-only because a non-atomic
12
+ download/upload/delete sequence could delete a newer source generation. Add a
13
+ separate `write` permission and an AI tool factory mutation-mode switch whose
14
+ default remains conditional.
15
+
16
+ Add bounded binary workspace reads through `readBytes`, alongside the existing
17
+ UTF-8 `readText` API. `readBytes` is a required `StorageWorkspace` member, so
18
+ custom interface implementations and typed test doubles must add it when
19
+ upgrading; workspaces returned by `mountStorageWorkspace` need no changes.
20
+
21
+ ### Patch Changes
22
+
23
+ - 37e0d8d: Fail conditional storage operations closed when caller-configured Files SDK
24
+ plugins, hooks, or receipts would be bypassed by native adapter extensions.
25
+ Ordinary operations continue through Files SDK while incompatible conditional
26
+ capabilities are hidden until Files SDK exposes one shared interception boundary.
27
+
3
28
  ## 0.1.0-alpha.8
4
29
 
5
30
  ### Minor Changes
package/README.md CHANGED
@@ -167,6 +167,7 @@ const workspace = mountStorageWorkspace(agentFiles, {
167
167
  'list',
168
168
  'read',
169
169
  'search',
170
+ 'write',
170
171
  'create',
171
172
  'replace',
172
173
  'copy',
@@ -199,8 +200,16 @@ await workspace.writeFile('src/main.ts', 'export const ready = false;\n', {
199
200
  etag: created.etag,
200
201
  contentType: 'text/typescript',
201
202
  });
203
+
204
+ const image = await workspace.readBytes('assets/logo.png');
205
+ console.log(image.bytes.byteLength);
202
206
  ```
203
207
 
208
+ `readBytes` is now a required member of the exported `StorageWorkspace`
209
+ interface. Workspaces returned by `mountStorageWorkspace` provide it
210
+ automatically; custom implementations and typed test doubles must add the
211
+ method when adopting this alpha minor.
212
+
204
213
  Create, replace, and delete are conditional operations. A driver that cannot
205
214
  enforce the requested not-exists or ETag precondition fails with
206
215
  `NOT_SUPPORTED`; the workspace never substitutes an `exists()`/`head()` check
@@ -215,6 +224,32 @@ the call returns `CONFLICT`; inspect both logical paths before retrying. This
215
224
  preserves at least one copy across provider timeouts and post-operation hook
216
225
  failures, but does not pretend a multi-object move is transactionally atomic.
217
226
 
227
+ Callers that prefer the ordinary Files pipeline can opt into explicit
228
+ last-write-wins variants. The `write` permission is separate from conditional
229
+ `create` and `replace` authority:
230
+
231
+ ```ts
232
+ await workspace.writeFile('notes.txt', 'latest contents', {
233
+ mode: 'overwrite',
234
+ });
235
+ await workspace.copyFile('notes.txt', 'backup.txt', { mode: 'overwrite' });
236
+ await workspace.deleteFile('backup.txt', { mode: 'unconditional' });
237
+ ```
238
+
239
+ Overwrite copy reads the latest source through the ordinary download pipeline,
240
+ enforces `maxWriteBytes` while collecting it, and uploads it through the
241
+ ordinary upload pipeline. It never substitutes the provider's server-side
242
+ copy. These paths compose with Files SDK plugins, hooks, and receipts, including
243
+ the built-in `encryption()` plugin. That plugin is useful compatibility
244
+ evidence, not an Artifact-specific security policy: strict encrypted-only
245
+ reads, tenant/path-bound AAD, key custody and rotation, and copy/move rules
246
+ remain application-owned.
247
+
248
+ Move remains conditional-only. A last-write-wins download/upload/delete
249
+ sequence could copy one source generation and then delete a newer generation
250
+ written during the transfer. Use the ETag-conditional `moveFile` variant when a
251
+ move is required.
252
+
218
253
  A child mount may further restrict a directory, permissions, or limits, but it
219
254
  cannot widen any of them:
220
255
 
@@ -275,6 +310,7 @@ import type { ToolSet } from 'ai';
275
310
  'list',
276
311
  'read',
277
312
  'search',
313
+ 'write',
278
314
  'create',
279
315
  'replace',
280
316
  'copy',
@@ -312,6 +348,13 @@ workspace capability remains the authorization boundary even when approval is
312
348
  disabled. The module's `AiSdkService.files()` API is the model provider's file
313
349
  upload facility and is unrelated to storage workspaces.
314
350
 
351
+ `mutationMode` defaults to `'conditional'`. A trusted composition can instead
352
+ select `{ mutationMode: 'last-write-wins' }`; generated mutation schemas then
353
+ omit ETags and modes, hardcode the explicit overwrite/unconditional workspace
354
+ variants, and require `write` permission for destination mutations.
355
+ Unconditional delete requires both `write` and `delete`. The move tool is
356
+ omitted in last-write-wins mode because Workspace move remains conditional-only.
357
+
315
358
  Atomic create collisions remain sanitized tool errors by default. Applications
316
359
  that model an existing destination as a normal tool result can map that one
317
360
  case while preserving replace/ETag conflicts as failures:
@@ -328,7 +371,8 @@ const tools = createAiSdkWorkspaceTools({
328
371
  ```
329
372
 
330
373
  The mapper receives only the logical workspace path; provider errors, object
331
- keys, and mount coordinates are never exposed.
374
+ keys, and mount coordinates are never exposed. `mapCreateConflict` is valid
375
+ only in conditional mode and is rejected with last-write-wins mode.
332
376
 
333
377
  This logical confinement is sufficient for a `ToolLoopAgent` whose only file
334
378
  capabilities are these tools. It cannot constrain a coding harness that already
@@ -555,6 +599,47 @@ run the reusable
555
599
  against dedicated test credentials. Unknown endpoints are forced read-only and
556
600
  receive no inferred conditional capabilities.
557
601
 
602
+ ## Files SDK responsibility boundary
603
+
604
+ Files SDK is the upstream authority for the generic storage data plane:
605
+ provider adapters, generic CRUD, bulk and list operations, retries, transfers
606
+ and sync, its plugin pipeline, and framework-neutral gateway mechanics.
607
+ `@nestm/storage` retains the guarantees that Files SDK does not currently
608
+ provide: NestJS 12 named stores, exact native conditional/CAS capabilities,
609
+ `StorageWorkspace` permissions and limits, bounded storage errors, and
610
+ capability-scoped AI tools.
611
+
612
+ On the alpha.8 base, ordinary `FilesSdkStorageDriver` operations already
613
+ delegate to the Files SDK pipeline. The exception is the native conditional
614
+ adapter extensions: the current Files SDK operation union does not include
615
+ them, so they cannot run through caller-configured Files plugins, hooks, or
616
+ receipts. Until Files SDK provides one interception boundary for ordinary and
617
+ conditional operations, the driver applies this interim fail-closed
618
+ compatibility rule:
619
+
620
+ | Caller Files configuration | Ordinary operations | Conditional operations |
621
+ | ------------------------------------------------- | ------------------------------------ | -------------------------------------------------------- |
622
+ | No plugins, active hooks, or receipts | Files pipeline | Advertised when the adapter supports the exact primitive |
623
+ | One or more plugins | Files pipeline, including transforms | Hidden; direct invocation returns `NOT_SUPPORTED` |
624
+ | Any active hook | Files pipeline and hook callbacks | Hidden; direct invocation returns `NOT_SUPPORTED` |
625
+ | Receipts enabled with `true` or an options object | Files pipeline and receipts | Hidden; direct invocation returns `NOT_SUPPORTED` |
626
+
627
+ An empty plugin list, an empty hooks object, and `receipts: false` do not trigger
628
+ the gate. NestM's internal physical-key guard does not trigger it either. When
629
+ available, direct conditional paths independently apply prefixing, the
630
+ physical-key budget, mutation read-only restrictions, default
631
+ retry/signal/timeout options, and bounded error mapping. `StoragePlugin` remains
632
+ a separate veto/observation boundary; it is not a substitute for Files body or
633
+ result transforms. This compatibility gate is intended to be removed once
634
+ native CAS can traverse the upstream operation and plugin pipeline rather than
635
+ becoming a second generic CRUD facade here.
636
+
637
+ `StorageWorkspace` therefore exposes both contracts without weakening either:
638
+ its existing create/replace, exact-read copy/move, and conditional-delete paths
639
+ retain native CAS and this fail-closed gate, while explicit overwrite and
640
+ unconditional-delete variants use the ordinary Files pipeline. Lower-level
641
+ conditional client and driver APIs remain available to callers that need them.
642
+
558
643
  ## Storage API
559
644
 
560
645
  `StorageClient` exposes:
@@ -9,6 +9,7 @@ export type AiSdkWorkspaceMutationToolName = Extract<AiSdkWorkspaceToolName, 'wo
9
9
  * approval, matching an omitted policy.
10
10
  */
11
11
  export type AiSdkWorkspaceApprovalConfig = boolean | Partial<Record<AiSdkWorkspaceMutationToolName, boolean>>;
12
+ export type AiSdkWorkspaceMutationMode = 'conditional' | 'last-write-wins';
12
13
  export interface AiSdkWorkspaceCreateConflict {
13
14
  /** The logical destination inside the mounted workspace. */
14
15
  readonly path: string;
@@ -24,10 +25,16 @@ export interface CreateAiSdkWorkspaceToolsOptions<CreateConflictResult extends J
24
25
  maxReadBytes?: number;
25
26
  /** Mutation tools require approval by default. */
26
27
  requireApproval?: AiSdkWorkspaceApprovalConfig;
28
+ /**
29
+ * Selects the mutation contract exposed to the model. Conditional mode is
30
+ * the default; last-write-wins uses the workspace's ordinary Files path.
31
+ */
32
+ mutationMode?: AiSdkWorkspaceMutationMode;
27
33
  /**
28
34
  * Maps an atomic create collision to an application result. When omitted,
29
35
  * the collision remains an AiSdkWorkspaceToolError like every other storage
30
- * failure. Replace conflicts are never mapped by this hook.
36
+ * failure. Replace conflicts are never mapped by this hook. This option is
37
+ * valid only when mutationMode is conditional.
31
38
  */
32
39
  mapCreateConflict?: AiSdkWorkspaceCreateConflictMapper<CreateConflictResult>;
33
40
  }
@@ -69,6 +76,6 @@ export interface AiSdkWorkspacePageResult {
69
76
  * The workspace remains the enforcing boundary if a retained tool reference
70
77
  * is invoked after further narrowing.
71
78
  */
72
- export declare function createAiSdkWorkspaceTools<CreateConflictResult extends JSONValue = never>({ workspace, maxReadBytes: requestedMaxReadBytes, requireApproval, mapCreateConflict, }: CreateAiSdkWorkspaceToolsOptions<CreateConflictResult>): ToolSet;
79
+ export declare function createAiSdkWorkspaceTools<CreateConflictResult extends JSONValue = never>({ workspace, maxReadBytes: requestedMaxReadBytes, mutationMode, requireApproval, mapCreateConflict, }: CreateAiSdkWorkspaceToolsOptions<CreateConflictResult>): ToolSet;
73
80
  export declare function isAiSdkWorkspaceMutationToolName(value: string): value is AiSdkWorkspaceMutationToolName;
74
81
  //# sourceMappingURL=ai-sdk-workspace-tools.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ai-sdk-workspace-tools.d.ts","sourceRoot":"","sources":["../../src/ai-sdk/ai-sdk-workspace-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,SAAS,EAAE,KAAK,OAAO,EAAE,MAAM,IAAI,CAAC;AAGxD,OAAO,EAGL,KAAK,gBAAgB,EAItB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAGL,KAAK,gBAAgB,IAAI,qBAAqB,EAC/C,MAAM,qBAAqB,CAAC;AAG7B,eAAO,MAAM,2BAA2B,YACtC,gBAAgB,EAChB,gBAAgB,EAChB,qBAAqB,EACrB,kBAAkB,EAClB,sBAAsB,EACtB,qBAAqB,EACrB,qBAAqB,EACrB,uBAAuB,CACf,CAAC;AAEX,MAAM,MAAM,sBAAsB,GAChC,CAAC,OAAO,2BAA2B,CAAC,CAAC,MAAM,CAAC,CAAC;AAE/C,MAAM,MAAM,8BAA8B,GAAG,OAAO,CAClD,sBAAsB,EACpB,sBAAsB,GACtB,qBAAqB,GACrB,qBAAqB,GACrB,uBAAuB,CAC1B,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,4BAA4B,GACtC,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,8BAA8B,EAAE,OAAO,CAAC,CAAC,CAAC;AAErE,MAAM,WAAW,4BAA4B;IAC3C,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,MAAM,kCAAkC,CAAC,MAAM,SAAS,SAAS,IAAI,CACzE,QAAQ,EAAE,4BAA4B,KACnC,WAAW,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;AAElC,MAAM,WAAW,gCAAgC,CAC/C,oBAAoB,SAAS,SAAS,GAAG,KAAK;IAE9C,4EAA4E;IAC5E,SAAS,EAAE,gBAAgB,CAAC;IAC5B;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,kDAAkD;IAClD,eAAe,CAAC,EAAE,4BAA4B,CAAC;IAC/C;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,kCAAkC,CAAC,oBAAoB,CAAC,CAAC;CAC9E;AAED,MAAM,MAAM,2BAA2B,GAAG,qBAAqB,CAAC;AAsBhE;;;GAGG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;IAChD,QAAQ,CAAC,IAAI,EAAE,2BAA2B,CAAC;IAE3C,YAAY,IAAI,EAAE,2BAA2B,EAI5C;CACF;AAED,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,6BAA6B;IAC5C,IAAI,EAAE,WAAW,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,MAAM,yBAAyB,GACnC,wBAAwB,GAAG,6BAA6B,CAAC;AAE3D,MAAM,WAAW,4BAA6B,SAAQ,wBAAwB;IAC5E,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,yBAAyB,EAAE,CAAC;IACrC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA2OD;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,oBAAoB,SAAS,SAAS,GAAG,KAAK,EAC9C,EACA,SAAS,EACT,YAAY,EAAE,qBAAqB,EACnC,eAAsB,EACtB,iBAAiB,GAClB,EAAE,gCAAgC,CAAC,oBAAoB,CAAC,GAAG,OAAO,CA6SlE;AAED,wBAAgB,gCAAgC,CAC9C,KAAK,EAAE,MAAM,GACZ,KAAK,IAAI,8BAA8B,CAEzC"}
1
+ {"version":3,"file":"ai-sdk-workspace-tools.d.ts","sourceRoot":"","sources":["../../src/ai-sdk/ai-sdk-workspace-tools.ts"],"names":[],"mappings":"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"}
@@ -16,7 +16,7 @@ export const AI_SDK_WORKSPACE_TOOL_NAMES = [
16
16
  const SAFE_ERROR_MESSAGES = {
17
17
  [StorageErrorCode.NOT_FOUND]: 'The requested workspace path was not found.',
18
18
  [StorageErrorCode.UNAUTHORIZED]: 'This operation is not permitted in the workspace.',
19
- [StorageErrorCode.CONFLICT]: 'The operation conflicts with current workspace state. Refresh metadata and retry with the current ETag or a new destination.',
19
+ [StorageErrorCode.CONFLICT]: 'The operation conflicts with current workspace state. Inspect the affected paths before retrying.',
20
20
  [StorageErrorCode.READ_ONLY]: 'This operation is not permitted in the workspace.',
21
21
  [StorageErrorCode.INVALID_ARGUMENT]: 'The workspace tool input was rejected.',
22
22
  [StorageErrorCode.NOT_SUPPORTED]: 'This workspace operation is not supported by the configured storage.',
@@ -210,7 +210,13 @@ function serializePage(page) {
210
210
  * The workspace remains the enforcing boundary if a retained tool reference
211
211
  * is invoked after further narrowing.
212
212
  */
213
- export function createAiSdkWorkspaceTools({ workspace, maxReadBytes: requestedMaxReadBytes, requireApproval = true, mapCreateConflict, }) {
213
+ export function createAiSdkWorkspaceTools({ workspace, maxReadBytes: requestedMaxReadBytes, mutationMode = 'conditional', requireApproval = true, mapCreateConflict, }) {
214
+ if (mutationMode !== 'conditional' && mutationMode !== 'last-write-wins') {
215
+ throw new RangeError('mutationMode must be "conditional" or "last-write-wins".');
216
+ }
217
+ if (mutationMode === 'last-write-wins' && mapCreateConflict !== undefined) {
218
+ throw new RangeError('mapCreateConflict is available only in conditional mutation mode.');
219
+ }
214
220
  const maxReadBytes = resolveReadLimit(workspace, requestedMaxReadBytes);
215
221
  const tools = {};
216
222
  const pathSchema = logicalPath('File path', workspace.limits.maxPathBytes);
@@ -248,7 +254,9 @@ export function createAiSdkWorkspaceTools({ workspace, maxReadBytes: requestedMa
248
254
  }
249
255
  if (workspace.allows('read')) {
250
256
  tools.workspace_stat = tool({
251
- description: 'Inspect a file inside the mounted workspace without reading its contents. Returns the ETag required for safe replace, move, and delete operations.',
257
+ description: mutationMode === 'conditional'
258
+ ? 'Inspect a file inside the mounted workspace without reading its contents. Returns the ETag required for safe replace, move, and delete operations.'
259
+ : 'Inspect a file inside the mounted workspace without reading its contents. Any returned ETag is informational in last-write-wins mode.',
252
260
  strict: true,
253
261
  inputSchema: z.object({ path: pathSchema }).strict(),
254
262
  execute: ({ path }, { abortSignal }) => executeSafely(abortSignal, async () => serializeFile(await workspace.stat(path, operationOptions(abortSignal)))),
@@ -291,62 +299,96 @@ export function createAiSdkWorkspaceTools({ workspace, maxReadBytes: requestedMa
291
299
  }))),
292
300
  });
293
301
  }
294
- const canCreate = workspace.allows('create');
295
- const canReplace = workspace.allows('replace');
296
- if (canCreate || canReplace) {
297
- const commonWriteShape = {
298
- path: pathSchema,
299
- content: z
300
- .string()
301
- .refine((value) => utf8Encoder.encode(value).byteLength <=
302
- workspace.limits.maxWriteBytes, {
303
- message: `Content exceeds the ${workspace.limits.maxWriteBytes}-byte workspace write limit.`,
304
- })
305
- .describe(`UTF-8 text to write. The workspace enforces its ${workspace.limits.maxWriteBytes}-byte write limit.`),
306
- };
307
- const createSchema = z
308
- .object({ ...commonWriteShape, mode: z.literal('create') })
309
- .strict();
310
- const replaceSchema = z
311
- .object({
312
- ...commonWriteShape,
313
- mode: z.literal('replace'),
314
- etag: etagSchema,
302
+ const commonWriteShape = {
303
+ path: pathSchema,
304
+ content: z
305
+ .string()
306
+ .refine((value) => utf8Encoder.encode(value).byteLength <=
307
+ workspace.limits.maxWriteBytes, {
308
+ message: `Content exceeds the ${workspace.limits.maxWriteBytes}-byte workspace write limit.`,
315
309
  })
316
- .strict();
317
- const inputSchema = canCreate && canReplace
318
- ? z.discriminatedUnion('mode', [createSchema, replaceSchema])
319
- : canCreate
320
- ? createSchema
321
- : replaceSchema;
310
+ .describe(`UTF-8 text to write. The workspace enforces its ${workspace.limits.maxWriteBytes}-byte write limit.`),
311
+ };
312
+ if (mutationMode === 'last-write-wins' && workspace.allows('write')) {
313
+ const inputSchema = z.object(commonWriteShape).strict();
322
314
  tools.workspace_write_file = tool({
323
- description: canCreate && canReplace
324
- ? '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.'
325
- : canCreate
326
- ? 'Create a new UTF-8 text file inside the mounted workspace. The operation fails if the destination already exists.'
327
- : 'Replace an existing UTF-8 text file inside the mounted workspace using its current ETag.',
328
- // The combined create/replace schema is a discriminated union. OpenAI
329
- // strict function tools reject its root-level oneOf, while the runtime
330
- // Zod schema continues to validate every tool call in non-strict mode.
331
- strict: !(canCreate && canReplace),
315
+ description: 'Write a UTF-8 text file inside the mounted workspace. An existing destination is overwritten; the last successful writer wins.',
316
+ strict: true,
332
317
  inputSchema,
333
318
  needsApproval: resolveApproval('workspace_write_file', requireApproval),
334
- execute: (input, { abortSignal }) => executeCreateAware(abortSignal, input, async () => serializeFile(input.mode === 'create'
335
- ? await workspace.writeFile(input.path, input.content, {
336
- mode: 'create',
337
- ...operationOptions(abortSignal),
338
- })
339
- : await workspace.writeFile(input.path, input.content, {
340
- mode: 'replace',
341
- etag: input.etag,
342
- ...operationOptions(abortSignal),
343
- })), mapCreateConflict),
319
+ execute: ({ path, content }, { abortSignal }) => executeSafely(abortSignal, async () => serializeFile(await workspace.writeFile(path, content, {
320
+ mode: 'overwrite',
321
+ ...operationOptions(abortSignal),
322
+ }))),
344
323
  });
345
324
  }
346
- const canCopy = workspace.allows('copy') &&
347
- workspace.allows('read') &&
348
- workspace.allows('create');
349
- if (canCopy) {
325
+ else if (mutationMode === 'conditional') {
326
+ const canCreate = workspace.allows('create');
327
+ const canReplace = workspace.allows('replace');
328
+ if (canCreate || canReplace) {
329
+ const createSchema = z
330
+ .object({ ...commonWriteShape, mode: z.literal('create') })
331
+ .strict();
332
+ const replaceSchema = z
333
+ .object({
334
+ ...commonWriteShape,
335
+ mode: z.literal('replace'),
336
+ etag: etagSchema,
337
+ })
338
+ .strict();
339
+ const inputSchema = canCreate && canReplace
340
+ ? z.discriminatedUnion('mode', [createSchema, replaceSchema])
341
+ : canCreate
342
+ ? createSchema
343
+ : replaceSchema;
344
+ tools.workspace_write_file = tool({
345
+ description: canCreate && canReplace
346
+ ? '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.'
347
+ : canCreate
348
+ ? 'Create a new UTF-8 text file inside the mounted workspace. The operation fails if the destination already exists.'
349
+ : 'Replace an existing UTF-8 text file inside the mounted workspace using its current ETag.',
350
+ // The combined create/replace schema is a discriminated union. OpenAI
351
+ // strict function tools reject its root-level oneOf, while the runtime
352
+ // Zod schema continues to validate every tool call in non-strict mode.
353
+ strict: !(canCreate && canReplace),
354
+ inputSchema,
355
+ needsApproval: resolveApproval('workspace_write_file', requireApproval),
356
+ execute: (input, { abortSignal }) => executeCreateAware(abortSignal, input, async () => serializeFile(input.mode === 'create'
357
+ ? await workspace.writeFile(input.path, input.content, {
358
+ mode: 'create',
359
+ ...operationOptions(abortSignal),
360
+ })
361
+ : await workspace.writeFile(input.path, input.content, {
362
+ mode: 'replace',
363
+ etag: input.etag,
364
+ ...operationOptions(abortSignal),
365
+ })), mapCreateConflict),
366
+ });
367
+ }
368
+ }
369
+ const canCopy = workspace.allows('copy') && workspace.allows('read');
370
+ if (mutationMode === 'last-write-wins' &&
371
+ canCopy &&
372
+ workspace.allows('write')) {
373
+ tools.workspace_copy_file = tool({
374
+ description: 'Copy the latest readable contents of a file inside the mounted workspace. The source remains intact, and an existing destination is overwritten.',
375
+ strict: true,
376
+ inputSchema: z
377
+ .object({
378
+ source: logicalPath('Source file path', workspace.limits.maxPathBytes),
379
+ destination: logicalPath('Destination file path', workspace.limits.maxPathBytes),
380
+ })
381
+ .strict(),
382
+ needsApproval: resolveApproval('workspace_copy_file', requireApproval),
383
+ execute: ({ source, destination }, { abortSignal }) => executeSafely(abortSignal, async () => serializeFile(await workspace.copyFile(source, destination, {
384
+ mode: 'overwrite',
385
+ ...operationOptions(abortSignal),
386
+ }))),
387
+ });
388
+ }
389
+ else if (mutationMode === 'conditional' &&
390
+ canCopy &&
391
+ workspace.allows('create')) {
350
392
  tools.workspace_copy_file = tool({
351
393
  description: 'Copy an exact observed version of a file inside the mounted workspace. The source remains intact, and the operation fails if the source changed or the destination already exists.',
352
394
  strict: true,
@@ -364,11 +406,11 @@ export function createAiSdkWorkspaceTools({ workspace, maxReadBytes: requestedMa
364
406
  }))),
365
407
  });
366
408
  }
367
- const canMove = workspace.allows('move') &&
409
+ if (mutationMode === 'conditional' &&
410
+ workspace.allows('move') &&
368
411
  workspace.allows('read') &&
369
- workspace.allows('create') &&
370
- workspace.allows('delete');
371
- if (canMove) {
412
+ workspace.allows('delete') &&
413
+ workspace.allows('create')) {
372
414
  tools.workspace_move_file = tool({
373
415
  description: "Move a file inside the mounted workspace using the source's current ETag. The operation fails if the destination already exists. If source deletion cannot be confirmed, the destination is retained and the tool reports a conflict; inspect both paths before retrying.",
374
416
  strict: true,
@@ -386,7 +428,24 @@ export function createAiSdkWorkspaceTools({ workspace, maxReadBytes: requestedMa
386
428
  }))),
387
429
  });
388
430
  }
389
- if (workspace.allows('delete')) {
431
+ if (mutationMode === 'last-write-wins' &&
432
+ workspace.allows('delete') &&
433
+ workspace.allows('write')) {
434
+ tools.workspace_delete_file = tool({
435
+ description: 'Unconditionally delete the current file at a path inside the mounted workspace.',
436
+ strict: true,
437
+ inputSchema: z.object({ path: pathSchema }).strict(),
438
+ needsApproval: resolveApproval('workspace_delete_file', requireApproval),
439
+ execute: ({ path }, { abortSignal }) => executeSafely(abortSignal, async () => {
440
+ await workspace.deleteFile(path, {
441
+ mode: 'unconditional',
442
+ ...operationOptions(abortSignal),
443
+ });
444
+ return { deleted: true, path };
445
+ }),
446
+ });
447
+ }
448
+ else if (mutationMode === 'conditional' && workspace.allows('delete')) {
390
449
  tools.workspace_delete_file = tool({
391
450
  description: 'Delete a file inside the mounted workspace using its current ETag.',
392
451
  strict: true,