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
2 changes: 0 additions & 2 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.6",
"@hono/zod-validator": "^0.4.3",
"@mariozechner/pi-agent-core": "^0.54.2",
"@mariozechner/pi-coding-agent": "^0.54.2",
"@modelcontextprotocol/sdk": "^1.25.2",
"@openrouter/ai-sdk-provider": "^2.2.3",
"@sentry/bun": "^10.31.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/agent/tool-loop/ai-sdk-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { stepCountIs, ToolLoopAgent, type UIMessage } from 'ai'
import type { Browser } from '../../browser/browser'
import type { KlavisClient } from '../../lib/clients/klavis/klavis-client'
import { logger } from '../../lib/logger'
import { buildFilesystemToolSet } from '../../tools/filesystem/build-toolset'
import type { ToolRegistry } from '../../tools/tool-registry'
import { buildSystemPrompt } from '../prompt'
import type { ResolvedAgentConfig } from '../types'
import { createCompactionPrepareStep } from './compaction'
import { buildFilesystemToolSet } from './filesystem-tools/pi-tool-adapter'
import { buildMcpServerSpecs, createMcpClients } from './mcp-builder'
import { createLanguageModel } from './provider-factory'
import { buildBrowserToolSet } from './tool-adapter'
Expand Down
133 changes: 0 additions & 133 deletions apps/server/src/agent/tool-loop/filesystem-tools/pi-tool-adapter.ts

This file was deleted.

84 changes: 84 additions & 0 deletions apps/server/src/tools/filesystem/bash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { resolve } from 'node:path'
import { tool } from 'ai'
import { z } from 'zod'
import {
DEFAULT_BASH_TIMEOUT,
executeWithMetrics,
toModelOutput,
truncateTail,
} from './utils'

const TOOL_NAME = 'filesystem_bash'

function getShellArgs(): [string, string] {
if (process.platform === 'win32') return ['cmd.exe', '/c']
return [process.env.SHELL || '/bin/sh', '-c']
}

export function createBashTool(cwd: string) {
return tool({
description:
'Execute a shell command and return its output. Commands run in a shell (sh/bash on Unix, cmd on Windows). Output is truncated to the last 2000 lines if too large.',
inputSchema: z.object({
command: z.string().describe('Shell command to execute'),
timeout: z
.number()
.optional()
.describe(`Timeout in seconds (default: ${DEFAULT_BASH_TIMEOUT})`),
}),
execute: (params) =>
executeWithMetrics(TOOL_NAME, async () => {
const [shell, flag] = getShellArgs()
const timeoutMs = (params.timeout || DEFAULT_BASH_TIMEOUT) * 1000
const resolvedCwd = resolve(cwd)

const proc = Bun.spawn([shell, flag, params.command], {
cwd: resolvedCwd,
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env },
})

let timedOut = false
const timer = setTimeout(() => {
timedOut = true
proc.kill()
}, timeoutMs)

const [stdoutText, stderrText] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])

const exitCode = await proc.exited
clearTimeout(timer)

if (timedOut) {
let output = stdoutText
if (stderrText) output += (output ? '\n' : '') + stderrText
const truncated = truncateTail(output)
return {
text: `Command timed out after ${params.timeout || DEFAULT_BASH_TIMEOUT}s\n\n${truncated.content}`,
isError: true,
}
}

let output = stdoutText
if (stderrText) output += (output ? '\n' : '') + stderrText

const truncated = truncateTail(output)
let result = truncated.content
if (truncated.truncated) {
result = `(Output truncated. Showing last ${truncated.keptLines} of ${truncated.totalLines} lines)\n${result}`
}

if (exitCode !== 0) {
result += `\n\n[Exit code: ${exitCode}]`
return { text: result, isError: true }
}

return { text: result || '(no output)' }
}),
toModelOutput,
})
}
20 changes: 20 additions & 0 deletions apps/server/src/tools/filesystem/build-toolset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ToolSet } from 'ai'
import { createBashTool } from './bash'
import { createEditTool } from './edit'
import { createFindTool } from './find'
import { createGrepTool } from './grep'
import { createLsTool } from './ls'
import { createReadTool } from './read'
import { createWriteTool } from './write'

export function buildFilesystemToolSet(cwd: string): ToolSet {
return {
filesystem_read: createReadTool(cwd),
filesystem_write: createWriteTool(cwd),
filesystem_edit: createEditTool(cwd),
filesystem_bash: createBashTool(cwd),
filesystem_grep: createGrepTool(cwd),
filesystem_find: createFindTool(cwd),
filesystem_ls: createLsTool(cwd),
}
}
134 changes: 134 additions & 0 deletions apps/server/src/tools/filesystem/edit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { tool } from 'ai'
import { z } from 'zod'
import {
detectLineEnding,
executeWithMetrics,
normalizeToLF,
restoreLineEndings,
stripBom,
toModelOutput,
} from './utils'

const TOOL_NAME = 'filesystem_edit'

function countOccurrences(content: string, search: string): number {
let count = 0
let pos = 0
while (true) {
pos = content.indexOf(search, pos)
if (pos === -1) break
count++
pos += search.length
}
return count
}

function fuzzyReplace(
content: string,
oldStr: string,
newStr: string,
): string | null {
const contentLines = content.split('\n')
const searchLines = oldStr.split('\n')
const trimmedSearch = searchLines.map((l) => l.trim())

let matchCount = 0
let matchStartLine = -1

for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
let allMatch = true
for (let j = 0; j < searchLines.length; j++) {
if (contentLines[i + j].trim() !== trimmedSearch[j]) {
allMatch = false
break
}
}
if (allMatch) {
matchCount++
if (matchCount === 1) matchStartLine = i
}
}

if (matchCount === 0) return null
if (matchCount > 1) {
throw new Error(
`Whitespace-tolerant match found ${matchCount} occurrences. Add more context to make the match unique.`,
)
}

const before = contentLines.slice(0, matchStartLine)
const after = contentLines.slice(matchStartLine + searchLines.length)
return [...before, ...newStr.split('\n'), ...after].join('\n')
}

function generateDiff(oldStr: string, newStr: string): string {
const oldLines = oldStr.split('\n')
const newLines = newStr.split('\n')
const lines: string[] = []
for (const line of oldLines) lines.push(`- ${line}`)
for (const line of newLines) lines.push(`+ ${line}`)
return lines.join('\n')
}

export function createEditTool(cwd: string) {
return tool({
description:
'Make a targeted edit to a file by replacing an exact string match. The old_string must match exactly one location in the file. If exact match fails, a whitespace-tolerant match is attempted.',
inputSchema: z.object({
path: z
.string()
.describe('File path (relative to working directory or absolute)'),
old_string: z.string().describe('Exact text to find in the file'),
new_string: z.string().describe('Replacement text'),
}),
execute: (params) =>
executeWithMetrics(TOOL_NAME, async () => {
const resolved = resolve(cwd, params.path)
const raw = await readFile(resolved, 'utf-8')

const { content: noBom, hasBom } = stripBom(raw)
const lineEnding = detectLineEnding(noBom)
const content = normalizeToLF(noBom)
const oldNorm = normalizeToLF(params.old_string)
const newNorm = normalizeToLF(params.new_string)

if (oldNorm === newNorm) {
return {
text: 'old_string and new_string are identical — no change needed.',
isError: true,
}
}

let updated: string
const exactCount = countOccurrences(content, oldNorm)

if (exactCount === 1) {
updated = content.replace(oldNorm, newNorm)
} else if (exactCount > 1) {
return {
text: `Found ${exactCount} exact occurrences of old_string. Add more surrounding context to make the match unique.`,
isError: true,
}
} else {
const fuzzyResult = fuzzyReplace(content, oldNorm, newNorm)
if (fuzzyResult === null) {
return {
text: 'old_string not found in file (exact and whitespace-tolerant match both failed).',
isError: true,
}
}
updated = fuzzyResult
}

let finalContent = restoreLineEndings(updated, lineEnding)
if (hasBom) finalContent = `\uFEFF${finalContent}`
await writeFile(resolved, finalContent, 'utf-8')

const diff = generateDiff(params.old_string, params.new_string)
return { text: `Applied edit to ${params.path}\n\n${diff}` }
}),
toModelOutput,
})
}
Loading
Loading