Skip to content

Commit a5e270c

Browse files
authored
Merge pull request #13 from jbreite/add-nullable-schemas-instead-optional
add: nullable schemas instead optional on zod
2 parents c1d2b1b + f0bfc65 commit a5e270c

File tree

15 files changed

+162
-59
lines changed

15 files changed

+162
-59
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,6 @@ dist
173173

174174
# Finder (MacOS) folder config
175175
.DS_Store
176+
177+
#workflow review
178+
/todos

AGENTS.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,14 @@ This is useful for:
9898

9999
| Tool | Purpose | Key Inputs |
100100
|------|---------|------------|
101-
| `Bash` | Execute shell commands | `command`, `timeout?`, `description?` |
102-
| `Read` | Read files or list directories | `file_path`, `offset?`, `limit?` |
101+
| `Bash` | Execute shell commands | `command`, `timeout`, `description` |
102+
| `Read` | Read files or list directories | `file_path`, `offset`, `limit` |
103103
| `Write` | Create/overwrite files | `file_path`, `content` |
104-
| `Edit` | Replace strings in files | `file_path`, `old_string`, `new_string`, `replace_all?` |
105-
| `Glob` | Find files by pattern | `pattern`, `path?` |
106-
| `Grep` | Search file contents | `pattern`, `path?`, `output_mode?`, `-i?`, `-C?` |
104+
| `Edit` | Replace strings in files | `file_path`, `old_string`, `new_string`, `replace_all` |
105+
| `Glob` | Find files by pattern | `pattern`, `path` |
106+
| `Grep` | Search file contents | `pattern`, `path`, `output_mode`, `-i`, `-C` |
107+
108+
> **Note on nullable types:** Optional parameters use `T | null` (not `T | undefined`) for OpenAI structured outputs compatibility. AI models should send explicit `null` for parameters they don't want to set. This works with both OpenAI and Anthropic models.
107109
108110
### Optional Tools (via config)
109111

CLAUDE.md

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
**Tech Stack**: TypeScript • Bun • Vercel AI SDK • Zod
66
**Inspired by**: Claude Code tools
7-
**Version**: 0.3.0
7+
**Version**: 0.4.0
88

99
---
1010

@@ -148,12 +148,42 @@ Zod schemas define and validate all tool inputs:
148148
```typescript
149149
const bashInputSchema = z.object({
150150
command: z.string(),
151-
description: z.string(),
152-
restart: z.boolean().optional()
151+
description: z.string().nullable(),
152+
timeout: z.number().nullable()
153153
});
154154
```
155155

156-
#### 6. Tool Result Caching
156+
#### 6. Nullable Types for AI Provider Compatibility
157+
158+
All optional tool parameters use `.nullable()` instead of `.optional()` for OpenAI structured outputs compatibility.
159+
160+
**Why `.nullable()` instead of `.optional()`:**
161+
- OpenAI structured outputs require all properties in the `required` array
162+
- `.optional()` removes properties from `required` (breaks OpenAI)
163+
- `.nullable()` keeps properties in `required` but allows `null` values
164+
- Works with both OpenAI and Anthropic models
165+
166+
**Pattern for handling nullable values:**
167+
```typescript
168+
// Zod schema uses .nullable()
169+
const schema = z.object({
170+
timeout: z.number().nullable(),
171+
replace_all: z.boolean().nullable(),
172+
});
173+
174+
// In execute function, use ?? for defaults
175+
// NOTE: Destructuring defaults (= value) only work with undefined, NOT null
176+
const { timeout, replace_all: rawReplaceAll } = input;
177+
const effectiveTimeout = timeout ?? 120000;
178+
const replaceAll = rawReplaceAll ?? false;
179+
```
180+
181+
**Type conventions:**
182+
- Zod schemas: `.nullable()` → produces `T | null`
183+
- Exported interfaces: `T | null` (e.g., `description: string | null`)
184+
- Internal functions: `T | null` for parameters that accept nullable values
185+
186+
#### 7. Tool Result Caching
157187
Optional caching for tool execution results:
158188
```typescript
159189
// Enable with defaults (LRU, 5min TTL)
@@ -841,5 +871,5 @@ const { tools } = createAgentTools(sandbox, {
841871

842872
---
843873

844-
*Last Updated*: 2026-01-02
874+
*Last Updated*: 2026-01-22
845875
*For*: Claude Code and AI coding assistants

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,32 @@ Agentic coding tools for Vercel AI SDK. Give AI agents the ability to execute co
1919
- Search the web and fetch URLs
2020
- Load skills on-demand via the [Agent Skills](https://agentskills.io) standard
2121

22+
## Breaking Changes in v0.4.0
23+
24+
### Nullable Types for OpenAI Compatibility
25+
26+
All optional tool parameters now use `.nullable()` instead of `.optional()` in Zod schemas. This change enables compatibility with OpenAI's structured outputs, which require all properties to be in the `required` array.
27+
28+
**What changed:**
29+
- Tool input types changed from `T | undefined` to `T | null`
30+
- Exported interfaces (`QuestionOption`, `StructuredQuestion`) use `T | null`
31+
- AI models will send explicit `null` values instead of omitting properties
32+
33+
**Migration:**
34+
```typescript
35+
// Before v0.4.0
36+
const option: QuestionOption = { label: "test", description: undefined };
37+
38+
// v0.4.0+
39+
const option: QuestionOption = { label: "test", description: null };
40+
```
41+
42+
**Why this matters:**
43+
- Works with both OpenAI and Anthropic models
44+
- OpenAI structured outputs require nullable (not optional) fields
45+
- Anthropic/Claude handles nullable fields correctly
46+
- The `??` operator handles both `null` and `undefined`, so runtime behavior is unchanged
47+
2248
## Installation
2349

2450
```bash

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "bashkit",
3-
"version": "0.3.2",
3+
"version": "0.4.0",
44
"description": "Agentic coding tools for the Vercel AI SDK",
55
"type": "module",
66
"main": "./dist/index.js",

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export type {
4141
AskUserError,
4242
AskUserOutput,
4343
AskUserResponseHandler,
44+
QuestionOption,
45+
StructuredQuestion,
4446
// Sandbox tools
4547
BashError,
4648
BashOutput,

src/tools/ask-user.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ import {
1010
// Option for structured questions
1111
export interface QuestionOption {
1212
label: string;
13-
description?: string;
13+
description: string | null;
1414
}
1515

1616
// Structured question with options
1717
export interface StructuredQuestion {
18-
header?: string; // Short label (max 12 chars), displayed as chip/tag
18+
header: string | null; // Short label (max 12 chars), displayed as chip/tag
1919
question: string;
20-
options?: QuestionOption[];
21-
multiSelect?: boolean;
20+
options: QuestionOption[] | null;
21+
multiSelect: boolean | null;
2222
}
2323

2424
// Simple question output (backward compatible)
@@ -54,15 +54,17 @@ const questionOptionSchema = z.object({
5454
),
5555
description: z
5656
.string()
57-
.optional()
57+
.nullable()
58+
.default(null)
5859
.describe("Explanation of what this option means or its implications."),
5960
});
6061

6162
// Schema for structured question
6263
const structuredQuestionSchema = z.object({
6364
header: z
6465
.string()
65-
.optional()
66+
.nullable()
67+
.default(null)
6668
.describe(
6769
"Very short label displayed as a chip/tag (max 12 chars). Examples: 'Auth method', 'Library', 'Approach'.",
6870
),
@@ -75,13 +77,15 @@ const structuredQuestionSchema = z.object({
7577
.array(questionOptionSchema)
7678
.min(2)
7779
.max(4)
78-
.optional()
80+
.nullable()
81+
.default(null)
7982
.describe(
8083
"Available choices for this question. 2-4 options. An 'Other' option is automatically available to users.",
8184
),
8285
multiSelect: z
8386
.boolean()
84-
.optional()
87+
.nullable()
88+
.default(null)
8589
.describe(
8690
"Set to true to allow the user to select multiple options instead of just one.",
8791
),
@@ -91,15 +95,17 @@ const structuredQuestionSchema = z.object({
9195
const askUserInputSchema = z.object({
9296
question: z
9397
.string()
94-
.optional()
98+
.nullable()
99+
.default(null)
95100
.describe(
96101
"Simple question string (for backward compatibility). Use 'questions' for structured multi-choice.",
97102
),
98103
questions: z
99104
.array(structuredQuestionSchema)
100105
.min(1)
101106
.max(4)
102-
.optional()
107+
.nullable()
108+
.default(null)
103109
.describe("Structured questions with options (1-4 questions)."),
104110
});
105111

src/tools/bash.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,20 @@ const bashInputSchema = z.object({
2525
command: z.string().describe("The command to execute"),
2626
timeout: z
2727
.number()
28-
.optional()
28+
.nullable()
29+
.default(null)
2930
.describe("Optional timeout in milliseconds (max 600000)"),
3031
description: z
3132
.string()
32-
.optional()
33+
.nullable()
34+
.default(null)
3335
.describe(
3436
"Clear, concise description of what this command does in 5-10 words",
3537
),
3638
run_in_background: z
3739
.boolean()
38-
.optional()
40+
.nullable()
41+
.default(null)
3942
.describe("Set to true to run this command in the background"),
4043
});
4144

src/tools/edit.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ const editInputSchema = z.object({
2929
),
3030
replace_all: z
3131
.boolean()
32-
.optional()
32+
.nullable()
33+
.default(null)
3334
.describe("Replace all occurrences of old_string (default false)"),
3435
});
3536

@@ -65,8 +66,9 @@ export function createEditTool(sandbox: Sandbox, config?: ToolConfig) {
6566
file_path,
6667
old_string,
6768
new_string,
68-
replace_all = false,
69+
replace_all: rawReplaceAll,
6970
}: EditInput): Promise<EditOutput | EditError> => {
71+
const replace_all = rawReplaceAll ?? false;
7072
const startTime = performance.now();
7173
const debugId = isDebugEnabled()
7274
? debugStart("edit", {

src/tools/glob.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ const globInputSchema = z.object({
2727
),
2828
path: z
2929
.string()
30-
.optional()
30+
.nullable()
31+
.default(null)
3132
.describe("Directory to search in (defaults to working directory)"),
3233
});
3334

@@ -54,7 +55,7 @@ export function createGlobTool(sandbox: Sandbox, config?: ToolConfig) {
5455
pattern,
5556
path,
5657
}: GlobInput): Promise<GlobOutput | GlobError> => {
57-
const searchPath = path || ".";
58+
const searchPath = path ?? ".";
5859
const startTime = performance.now();
5960
const debugId = isDebugEnabled()
6061
? debugStart("glob", { pattern, path: searchPath })

0 commit comments

Comments
 (0)