Skip to content
Closed
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/native-tool-adapter'
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.

103 changes: 103 additions & 0 deletions apps/server/src/tools/filesystem/bash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { spawn } from 'node:child_process'
import { z } from 'zod'
import { truncateTail } from './truncate'
import type { FilesystemTool } from './types'

const bashInputSchema = z.object({
command: z.string().describe('Shell command to execute'),
timeout: z
.number()
.positive()
.optional()
.describe('Timeout in seconds (optional)'),
})

type BashInput = z.infer<typeof bashInputSchema>

function getShellCommand(): { shell: string; args: string[] } {
if (process.platform === 'win32') {
return { shell: 'cmd.exe', args: ['/d', '/s', '/c'] }
}
return { shell: process.env.SHELL || '/bin/bash', args: ['-lc'] }
}

async function runCommand(
command: string,
cwd: string,
timeoutSeconds?: number,
): Promise<{ exitCode: number | null; output: string; timedOut: boolean }> {
const { shell, args } = getShellCommand()

return new Promise((resolve, reject) => {
const child = spawn(shell, [...args, command], {
cwd,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
})

const outputParts: string[] = []
let timedOut = false

const timeoutId = timeoutSeconds
? setTimeout(() => {
timedOut = true
child.kill('SIGTERM')
}, timeoutSeconds * 1_000)
: undefined

child.stdout.on('data', (chunk: Buffer) => {
outputParts.push(chunk.toString('utf-8'))
})

child.stderr.on('data', (chunk: Buffer) => {
outputParts.push(chunk.toString('utf-8'))
})

child.on('error', (error) => {
if (timeoutId) clearTimeout(timeoutId)
reject(error)
})

child.on('close', (code) => {
if (timeoutId) clearTimeout(timeoutId)
resolve({
exitCode: code,
output: outputParts.join(''),
timedOut,
})
})
})
}

export const bashTool: FilesystemTool<BashInput> = {
name: 'bash',
description:
'Execute a shell command in the session directory. Returns combined stdout and stderr.',
inputSchema: bashInputSchema,
execute: async ({ command, timeout }, cwd) => {
const { exitCode, output, timedOut } = await runCommand(
command,
cwd,
timeout,
)
const truncation = truncateTail(output)

let text = truncation.content || '(no output)'
if (truncation.truncated) {
const startLine = truncation.totalLines - truncation.outputLines + 1
text += `\n\n[Showing lines ${startLine}-${truncation.totalLines} of ${truncation.totalLines}. Output truncated.]`
}

if (timedOut) {
throw new Error(`${text}\n\nCommand timed out after ${timeout} seconds`)
}

if (exitCode !== 0) {
throw new Error(`${text}\n\nCommand exited with code ${exitCode}`)
}

return {
content: [{ type: 'text', text }],
}
},
}
94 changes: 94 additions & 0 deletions apps/server/src/tools/filesystem/edit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { readFile, writeFile } from 'node:fs/promises'
import { z } from 'zod'
import { assertPathWithinCwd, resolvePathInCwd } from './path-utils'
import type { FilesystemTool } from './types'

const editInputSchema = z.object({
path: z.string().describe('Path to the file to edit (relative or absolute)'),
oldText: z
.string()
.describe('Exact text to find and replace (must be unique in the file)'),
newText: z.string().describe('New text to replace the old text with'),
})

type EditInput = z.infer<typeof editInputSchema>

function stripBom(content: string): { bom: string; text: string } {
return content.startsWith('\uFEFF')
? { bom: '\uFEFF', text: content.slice(1) }
: { bom: '', text: content }
}

function detectLineEnding(content: string): '\r\n' | '\n' {
const crlfIndex = content.indexOf('\r\n')
const lfIndex = content.indexOf('\n')
if (lfIndex === -1 || crlfIndex === -1) return '\n'
return crlfIndex < lfIndex ? '\r\n' : '\n'
}

function normalizeToLf(content: string): string {
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}

function restoreLineEndings(
content: string,
lineEnding: '\r\n' | '\n',
): string {
return lineEnding === '\r\n' ? content.replace(/\n/g, '\r\n') : content
}

export const editTool: FilesystemTool<EditInput> = {
name: 'edit',
description:
'Edit a file by replacing exact text. The oldText must match exactly and be unique.',
inputSchema: editInputSchema,
execute: async ({ path: rawPath, oldText, newText }, cwd) => {
const absolutePath = resolvePathInCwd(rawPath, cwd)
assertPathWithinCwd(absolutePath, cwd)

const rawContent = await readFile(absolutePath, 'utf-8')
const { bom, text: withoutBom } = stripBom(rawContent)

const lineEnding = detectLineEnding(withoutBom)
const normalizedContent = normalizeToLf(withoutBom)
const normalizedOldText = normalizeToLf(oldText)
const normalizedNewText = normalizeToLf(newText)

if (!normalizedContent.includes(normalizedOldText)) {
throw new Error(
`Could not find the exact text in ${rawPath}. The oldText value must match exactly.`,
)
}

const occurrences = normalizedContent.split(normalizedOldText).length - 1
if (occurrences > 1) {
throw new Error(
`Found ${occurrences} occurrences in ${rawPath}. oldText must be unique.`,
)
}

const replacedContent = normalizedContent.replace(
normalizedOldText,
normalizedNewText,
)

if (replacedContent === normalizedContent) {
throw new Error(`No changes made to ${rawPath}.`)
}

await writeFile(
absolutePath,
`${bom}${restoreLineEndings(replacedContent, lineEnding)}`,
'utf-8',
)

return {
content: [
{
type: 'text',
text: `Successfully replaced text in ${rawPath}.`,
},
],
}
},
}
Loading
Loading