Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/src/app/api-reference/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,7 @@ const { tools, budget } = await createAgentTools(sandbox, {
</Prop>
<Prop name="context" type="ContextConfig">
Context layer config. Opt-in &mdash; wraps tools with execution
and output policies. See the{" "}
<a href="/context">Context</a> page.
and output policies. See the <a href="/context">Context</a> page.
</Prop>
</div>

Expand Down
15 changes: 7 additions & 8 deletions docs/src/app/context/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,9 @@ const policy = createOutputPolicy({
/>
<p>
When output exceeds <code>redirectionThreshold</code>, it gets
truncated to <code>maxOutputLength</code> and a{" "}
<code>_hint</code> field is added with tool-specific guidance (e.g.,
&quot;use <code>head</code>/<code>tail</code> to see specific
parts&quot;).
truncated to <code>maxOutputLength</code> and a <code>_hint</code>{" "}
field is added with tool-specific guidance (e.g., &quot;use{" "}
<code>head</code>/<code>tail</code> to see specific parts&quot;).
</p>

<h3>Custom Hints</h3>
Expand Down Expand Up @@ -198,8 +197,8 @@ const policy = createOutputPolicy({
<section>
<h2 id="system-prompt">System Prompt Assembly</h2>
<p>
<code>buildSystemContext</code> assembles a static system prompt from
three sources: discovered project instructions (AGENTS.md /
<code>buildSystemContext</code> assembles a static system prompt
from three sources: discovered project instructions (AGENTS.md /
CLAUDE.md files), environment info (cwd, platform, git branch), and
tool guidance.
</p>
Expand Down Expand Up @@ -230,8 +229,8 @@ ctx.environment // environment XML block
ctx.toolGuidance // tool hint list`}
/>
<p>
Call once at init &mdash; the output is deterministic and designed to
stay stable across turns for Anthropic prompt caching.
Call once at init &mdash; the output is deterministic and designed
to stay stable across turns for Anthropic prompt caching.
</p>
</section>

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bashkit",
"version": "0.7.0",
"version": "0.7.1",
"description": "Agentic coding tools for the Vercel AI SDK",
"type": "module",
"main": "./dist/index.js",
Expand Down
2 changes: 2 additions & 0 deletions src/context/tool-guidance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const DEFAULT_HINTS: Record<string, string> = {
Read: "Read files or list directories. Use offset/limit for large files.",
Write: "Create or overwrite files. Read first before overwriting.",
Edit: "Replace exact strings in files. Prefer over Write for modifications.",
Patch:
"Apply multi-hunk, multi-file edits in apply-patch format. Use for bulk edits, file additions/deletions, and renames.",
Glob: "Find files by pattern. Faster than bash find.",
Grep: "Search file contents with regex. Faster than bash grep.",
WebSearch: "Search the web. Use for current information.",
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export type {
PlanModeState,
GlobError,
GlobOutput,
PatchError,
PatchFileResult,
PatchOutput,
GrepContentOutput,
GrepCountOutput,
GrepError,
Expand Down Expand Up @@ -102,6 +105,7 @@ export {
createExitPlanModeTool,
createGlobTool,
createGrepTool,
createPatchTool,
createReadTool,
createSkillTool,
createTaskTool,
Expand Down
10 changes: 10 additions & 0 deletions src/sandbox/e2b.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,16 @@ export async function createE2BSandbox(
return result.exitCode === 0;
},

async deleteFile(path: string): Promise<void> {
const sbx = await sandbox.get();
await sbx.files.remove(path);
},

async rename(oldPath: string, newPath: string): Promise<void> {
const sbx = await sandbox.get();
await sbx.files.rename(oldPath, newPath);
},

async destroy(): Promise<void> {
try {
const sbx = await sandbox.get();
Expand Down
10 changes: 10 additions & 0 deletions src/sandbox/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ export interface Sandbox {
readDir(path: string): Promise<string[]>;
fileExists(path: string): Promise<boolean>;
isDirectory(path: string): Promise<boolean>;
/**
* Optional. When unimplemented, callers should fall back to `exec("rm -- ...")`.
* Built-in sandboxes (LocalSandbox, VercelSandbox, E2BSandbox) implement this.
*/
deleteFile?(path: string): Promise<void>;
/**
* Optional. When unimplemented, callers should fall back to `exec("mv -- ...")`.
* Built-in sandboxes implement this.
*/
rename?(oldPath: string, newPath: string): Promise<void>;
destroy(): Promise<void>;

/**
Expand Down
19 changes: 19 additions & 0 deletions src/sandbox/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,25 @@ export function createLocalSandbox(config: LocalSandboxConfig = {}): Sandbox {
}
},

async deleteFile(path: string): Promise<void> {
const fullPath = path.startsWith("/")
? path
: `${workingDirectory}/${path}`;
const fs = await import("fs/promises");
await fs.unlink(fullPath);
},

async rename(oldPath: string, newPath: string): Promise<void> {
const resolvedOldPath = oldPath.startsWith("/")
? oldPath
: `${workingDirectory}/${oldPath}`;
const resolvedNewPath = newPath.startsWith("/")
? newPath
: `${workingDirectory}/${newPath}`;
const fs = await import("fs/promises");
await fs.rename(resolvedOldPath, resolvedNewPath);
},

async destroy(): Promise<void> {
// No cleanup needed for local sandbox
},
Expand Down
11 changes: 11 additions & 0 deletions src/sandbox/shell-quote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* POSIX shell single-quote escaping for safe path interpolation.
*
* Wraps the input in single quotes and escapes any embedded single quote as
* `'\''`. Safe for use with `sh -c` / `bash -c` argv. Always combine with `--`
* after the command (e.g. `rm -- ${shellQuote(path)}`) so paths starting with
* `-` aren't parsed as flags.
*/
export function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
23 changes: 20 additions & 3 deletions src/sandbox/vercel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Sandbox as VercelSandboxType } from "@vercel/sandbox";
import type { ExecOptions, ExecResult, Sandbox } from "./interface";
import { createLazySingleton } from "./lazy-singleton";
import { ensureSandboxTools } from "./ensure-tools";
import { shellQuote } from "./shell-quote";

export interface VercelSandboxConfig {
runtime?: "node22" | "python3.13";
Expand Down Expand Up @@ -177,23 +178,39 @@ export async function createVercelSandbox(
},

async readDir(path: string): Promise<string[]> {
const result = await exec(`ls -1 ${path}`);
const result = await exec(`ls -1 -- ${shellQuote(path)}`);
if (result.exitCode !== 0) {
throw new Error(`Failed to read directory: ${result.stderr}`);
}
return result.stdout.split("\n").filter(Boolean);
},

async fileExists(path: string): Promise<boolean> {
const result = await exec(`test -e ${path}`);
const result = await exec(`test -e ${shellQuote(path)}`);
return result.exitCode === 0;
},

async isDirectory(path: string): Promise<boolean> {
const result = await exec(`test -d ${path}`);
const result = await exec(`test -d ${shellQuote(path)}`);
return result.exitCode === 0;
},

async deleteFile(path: string): Promise<void> {
const result = await exec(`rm -- ${shellQuote(path)}`);
if (result.exitCode !== 0) {
throw new Error(`Failed to delete: ${result.stderr}`);
}
},

async rename(oldPath: string, newPath: string): Promise<void> {
const result = await exec(
`mv -- ${shellQuote(oldPath)} ${shellQuote(newPath)}`,
);
if (result.exitCode !== 0) {
throw new Error(`Failed to rename: ${result.stderr}`);
}
},

async destroy(): Promise<void> {
try {
const sbx = await sandbox.get();
Expand Down
15 changes: 12 additions & 3 deletions src/tools/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
# Tools Module

The tools module implements all 15 AI agent tools in BashKit. These tools bridge AI models with sandbox execution environments, enabling agents to perform file operations, run commands, search code, fetch web content, manage workflows, and interact with users. Each tool follows the Vercel AI SDK tool() pattern with Zod schemas for input validation and structured error handling.
The tools module implements all 16 AI agent tools in BashKit. These tools bridge AI models with sandbox execution environments, enabling agents to perform file operations, run commands, search code, fetch web content, manage workflows, and interact with users. Each tool follows the Vercel AI SDK tool() pattern with Zod schemas for input validation and structured error handling.

Most tools are a single file. **Tools with non-trivial internals live in their own folder with their own `AGENTS.md`** (e.g. `patch/`). When a single-file tool grows past ~3 files of supporting modules, promote it to a folder.

## Files

| File | Purpose |
|------|---------|
| File / Folder | Purpose |
|---------------|---------|
| `bash.ts` | Execute shell commands with timeout and output limits |
| `read.ts` | Read files and list directories with pagination, per-line truncation (2000 chars), and total output truncation (60K chars) |
| `write.ts` | Write files with size limits and path restrictions |
| `edit.ts` | String-based find/replace editing with uniqueness validation |
| `patch/` | Multi-hunk / multi-file patches in OpenAI Codex `apply-patch` format. See `patch/AGENTS.md`. |
| `glob.ts` | Pattern-based file discovery using find command |
| `grep.ts` | Ripgrep-powered content search with context and filtering |
| `ask-user.ts` | Deferred structured user Q&A rendered by the client |
Expand All @@ -30,6 +33,7 @@ The tools module implements all 15 AI agent tools in BashKit. These tools bridge
- `createReadTool(sandbox, config?)` -- File/directory reading
- `createWriteTool(sandbox, config?)` -- File writing
- `createEditTool(sandbox, config?)` -- String replacement editing
- `createPatchTool(sandbox, config?)` -- Multi-hunk apply-patch editing (see `patch/AGENTS.md`)
- `createGlobTool(sandbox, config?)` -- File pattern matching
- `createGrepTool(sandbox, config?)` -- Content search with ripgrep
- `createAskUserTool(config?)` -- Deferred user interaction tool
Expand Down Expand Up @@ -65,6 +69,9 @@ Each tool exports `<Name>Output` for success and `<Name>Error` for errors:
**Core Sandbox Tools** (always enabled):
- Bash, Read, Write, Edit, Glob, Grep -- Direct sandbox operations via Sandbox interface

**Editing Tools** (opt-in via config):
- Patch -- Multi-hunk / multi-file apply-patch edits (overlaps with Edit; opt in via `patch: true`)

**Interactive Tools** (opt-in via config):
- AskUser -- Deferred structured user Q&A rendered by the client
- EnterPlanMode, ExitPlanMode -- Plan-then-execute workflow
Expand Down Expand Up @@ -194,6 +201,8 @@ All tool factories and types exported via `src/index.ts`:
6. Export types and factory from `index.ts`
7. Re-export from `src/index.ts`

If the tool grows beyond a single file (parser + applier + helpers), promote it to its own folder under `src/tools/your-tool/` with a barrel `index.ts` and a folder-scoped `AGENTS.md` (see `patch/` for the canonical example). Don't forget to run `bun run link-agents` after adding the AGENTS.md so CI's `check:agents` passes.

### Adding a New Web-Based Tool
Similar to sandbox tools but:
1. Import web API dynamically like web-fetch.ts (module cache pattern)
Expand Down
12 changes: 12 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { createEnterPlanModeTool, type PlanModeState } from "./enter-plan-mode";
import { createExitPlanModeTool } from "./exit-plan-mode";
import { createGlobTool } from "./glob";
import { createGrepTool } from "./grep";
import { createPatchTool } from "./patch";
import { createReadTool } from "./read";
import { createSkillTool } from "./skill";
import { createWebFetchTool } from "./web-fetch";
Expand Down Expand Up @@ -143,6 +144,7 @@ export interface AgentToolsResult {
*
* **Optional tools (via config):**
* - `askUser` — AskUser tool for clarifying questions
* - `patch` — Patch tool for multi-hunk / multi-file apply-patch edits
* - `planMode` — EnterPlanMode, ExitPlanMode for interactive planning
* - `skill` — Skill tool for skill execution
* - `webSearch` — WebSearch tool
Expand Down Expand Up @@ -197,6 +199,14 @@ export async function createAgentTools(
);
}

// Add Patch tool if configured
if (config?.patch) {
tools.Patch = createPatchTool(
sandbox,
config.patch === true ? undefined : config.patch,
);
}

// Add plan mode tools if configured
if (config?.planMode) {
planModeState = { isActive: false };
Expand Down Expand Up @@ -364,6 +374,8 @@ export type { ExitPlanModeError, ExitPlanModeOutput } from "./exit-plan-mode";
export { createExitPlanModeTool } from "./exit-plan-mode";
export type { GlobError, GlobOutput } from "./glob";
export { createGlobTool } from "./glob";
export type { PatchError, PatchFileResult, PatchOutput } from "./patch";
export { createPatchTool } from "./patch";

export type {
GrepContentOutput,
Expand Down
78 changes: 78 additions & 0 deletions src/tools/patch/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Patch Tool

The Patch tool applies file edits in OpenAI Codex's [`apply-patch`](https://github.com/openai/codex/tree/main/codex-rs/apply-patch) format. It complements `Edit` (single-string find/replace) with multi-hunk, multi-file, and rename/delete operations using fuzzy context-line matching.

This folder is a faithful port of `codex-rs/apply-patch`. Keep parser/applier behavior in lockstep with the upstream Rust implementation when fixing bugs — the test names in `tests/tools/patch.test.ts` reference Codex test IDs to make this easier.

## Files

| File | Purpose |
|------|---------|
| `tool.ts` | AI SDK `tool()` definition: parses, pre-flights, applies. Entry point. |
| `parser.ts` | Parses the apply-patch text format into `ParsedPatch` (Add/Delete/Update hunks). |
| `apply.ts` | Computes and applies replacements for Update hunks (`deriveNewContents`). |
| `seek-sequence.ts` | Four-tier fuzzy line matcher (exact → trimEnd → trim → unicode). |
| `types.ts` | Hunk/chunk/output/error types and `PatchParseError` / `PatchApplicationError`. |
| `index.ts` | Re-exports `createPatchTool` and the public types. |

## Key Exports

- `createPatchTool(sandbox, config?)` — factory producing the AI SDK tool.
- `parsePatch(input: string): ParsedPatch` — pure parser (used by tests and other consumers).
- `parseUpdateFileChunk(...)` — exposed for unit tests of the chunk-level grammar.
- `deriveNewContents(original, chunks, path): string` — pure applier, no I/O.
- `seekSequence(lines, pattern, start, eof)` / `normalizeUnicode(s)` — matching primitives.
- Types: `PatchOutput`, `PatchError`, `PatchFileResult`, `Hunk`, `UpdateFileChunk`, `ParsedPatch`.

## Architecture

```
patch text
parsePatch ──────► ParsedPatch (Hunk[])
┌── pre-flight (per hunk) ──┐
│ • allowedPaths check │
│ • Update: read + derive │
│ • Delete: fileExists │
│ • Add: maxFileSize │
│ • Move: target collision │
└────────────┬──────────────┘
│ all pass?
apply prepared ops
(writeFile / deleteFile)
```

Pre-flight runs **before** any sandbox writes. If a single hunk fails — bad context, missing file, size limit, move-target collision — the tool returns `{ error }` and the filesystem is unchanged. This isn't transactional atomicity (we can't roll back I/O after writes start), but it eliminates the common failure mode of "hunk 3/5 fails, hunks 1–2 already on disk."

The applier (`deriveNewContents`) is pure — it takes original content + chunks and returns new content as a string. All I/O lives in `tool.ts`.

### Sandbox method fallbacks

`Sandbox.deleteFile` is optional on the interface. When a custom sandbox doesn't implement it, the tool falls back to `sandbox.exec("rm -- ...")`, with paths quoted via `src/sandbox/shell-quote.ts`. Move operations are implemented as write-new-content plus delete-old-file (not `rename` / `mv`) because update chunks may modify the file content during the move. Built-in sandboxes (Local, Vercel, E2B) implement `deleteFile`, so the fallback is only relevant for third-party `Sandbox` implementers.

## Common Modifications

### Adding a new hunk type

1. Extend the `Hunk` union in `types.ts`.
2. Teach `parser.ts:parseOneHunk` to recognize the header.
3. Add a `case` to `tool.ts:prepareHunk` (validation + derive content) and to the apply switch in `tool.ts:execute`.
4. Add tests mirroring an existing hunk type in `tests/tools/patch.test.ts`.

### Tuning the fuzzy matcher

`seek-sequence.ts` runs four passes globally (exact → trimEnd → trim → unicode). Adding a tier means appending another `for (let i = searchStart; i <= maxIdx; i++)` pass. Keep the order: cheaper/stricter first. Update `normalizeUnicode` if you add new typographic mappings.

### Changing the patch format

The patch format is part of the public LLM contract — the tool description and the `patch` field's `.describe(...)` string both feed into the prompt. Format changes are breaking for any agent that learned the old format from a cached system prompt. Coordinate with a major version bump.

## Testing

`tests/tools/patch.test.ts` (63 tests) covers parser, chunk grammar, seek-sequence, unicode normalization, applier, and the tool-level integration path. Test names that reference `Codex: test_xxx` mirror the upstream Rust tests — keep them in sync.

When adding a new pre-flight check, add a test that asserts **no files were modified** when the check fires (use `sandbox.getFiles()` to snapshot pre/post).
1 change: 1 addition & 0 deletions src/tools/patch/CLAUDE.md
Loading
Loading