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
4 changes: 2 additions & 2 deletions 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 Expand Up @@ -45,7 +45,7 @@ export class AiSdkAgent {
})
const { clients, tools: externalMcpTools } = await createMcpClients(specs)

// Add filesystem tools (Pi coding agent) — skip in chat mode (read-only)
// Add filesystem tools — skip in chat mode (read-only)
const filesystemTools = config.resolvedConfig.chatMode
? {}
: buildFilesystemToolSet(config.resolvedConfig.sessionExecutionDir)
Expand Down
98 changes: 98 additions & 0 deletions apps/server/src/tools/filesystem/bash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import os from 'node:os'
import { z } from 'zod'
import type { FilesystemToolDef } from './build-toolset'
import { truncateTail } from './truncate'

function getShellConfig(): { executable: string; args: string[] } {
if (process.platform === 'win32') {
const comSpec = process.env.ComSpec?.toLowerCase() ?? ''
if (comSpec.endsWith('powershell.exe') || comSpec.endsWith('pwsh.exe')) {
return {
executable: process.env.ComSpec ?? 'powershell.exe',
args: ['-NoProfile', '-Command'],
}
}
return { executable: 'powershell.exe', args: ['-NoProfile', '-Command'] }
}
return { executable: 'bash', args: ['-c'] }
}

const shell = getShellConfig()
const shellLabel = process.platform === 'win32' ? 'PowerShell' : 'bash'

export const bash: FilesystemToolDef = {
name: 'bash',
description:
`Execute a shell command using ${shellLabel}. Returns stdout and stderr. ` +
'Output is truncated to last 2000 lines or 50KB. ' +
'Optionally provide a timeout in seconds.',
input: z.object({
command: z.string().describe('Shell command to execute'),
timeout: z
.number()
.optional()
.describe('Timeout in seconds (default: 120)'),
}),
async execute(args, cwd) {
const timeoutMs = (args.timeout ?? 120) * 1000

const proc = Bun.spawn([shell.executable, ...shell.args, args.command], {
cwd,
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
HOME: process.env.HOME ?? process.env.USERPROFILE ?? os.homedir(),
},
})

const timer = setTimeout(() => proc.kill(), timeoutMs)

let stdout = ''
let stderr = ''

try {
const [outBuf, errBuf] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])
stdout = outBuf
stderr = errBuf
} finally {
clearTimeout(timer)
}

const exitCode = await proc.exited

const combined = [stdout ? stdout : '', stderr ? `[stderr]\n${stderr}` : '']
.filter(Boolean)
.join('\n')

const result = truncateTail(combined)

const parts: string[] = []
if (result.truncated) {
parts.push(
`[Output truncated — showing last ${result.outputLines} of ${result.totalLines} lines]`,
)
}
parts.push(result.content)

if (exitCode !== 0) {
parts.push(`\n[Exit code: ${exitCode}]`)
return {
content: [{ type: 'text', text: parts.join('\n') }],
isError: true,
}
}

return {
content: [
{
type: 'text',
text: parts.join('\n') || 'Command completed with no output.',
},
],
}
},
}
Original file line number Diff line number Diff line change
@@ -1,37 +1,39 @@
import type { LanguageModelV2ToolResultOutput } from '@ai-sdk/provider'
import type { AgentTool, AgentToolResult } from '@mariozechner/pi-agent-core'
import {
createBashTool,
createEditTool,
createFindTool,
createGrepTool,
createLsTool,
createReadTool,
createWriteTool,
} from '@mariozechner/pi-coding-agent'
import { jsonSchema, type ToolSet, tool } from 'ai'
import { logger } from '../../../lib/logger'
import { metrics } from '../../../lib/metrics'
import { type ToolSet, tool } from 'ai'
import type { z } from 'zod'
import { logger } from '../../lib/logger'
import { metrics } from '../../lib/metrics'
import type { ContentItem, ToolResult } from '../response'
import { bash } from './bash'
import { edit } from './edit'
import { find } from './find'
import { grep } from './grep'
import { ls } from './ls'
import { read } from './read'
import { write } from './write'

type PiContent = AgentToolResult<unknown>['content']
export interface FilesystemToolDef {
name: string
description: string
input: z.ZodType
// biome-ignore lint/suspicious/noExplicitAny: tool params vary per tool
execute(args: any, cwd: string): Promise<ToolResult>
}

const ALL_TOOLS: FilesystemToolDef[] = [read, bash, edit, write, grep, find, ls]

function piContentToModelOutput(
content: PiContent,
): LanguageModelV2ToolResultOutput {
function contentToModelOutput(content: ContentItem[]) {
const hasImages = content.some((c) => c.type === 'image')

if (!hasImages) {
const text = content
.filter(
(c): c is PiContent[number] & { type: 'text' } => c.type === 'text',
)
.filter((c): c is ContentItem & { type: 'text' } => c.type === 'text')
.map((c) => c.text)
.join('\n')
return { type: 'text', value: text || 'Success' }
return { type: 'text' as const, value: text || 'Success' }
}

return {
type: 'content',
type: 'content' as const,
value: content.map((c) => {
if (c.type === 'text') {
return { type: 'text' as const, text: c.text }
Expand All @@ -45,45 +47,27 @@ function piContentToModelOutput(
}
}

// biome-ignore lint/suspicious/noExplicitAny: AgentTool is contravariant on TParameters — each createXxxTool returns a specific generic that can't assign to AgentTool<TSchema> without widening
function createAllTools(cwd: string): Record<string, AgentTool<any>> {
return {
read: createReadTool(cwd),
bash: createBashTool(cwd),
edit: createEditTool(cwd),
write: createWriteTool(cwd),
grep: createGrepTool(cwd),
find: createFindTool(cwd),
ls: createLsTool(cwd),
}
}

export function buildFilesystemToolSet(cwd: string): ToolSet {
const piTools = createAllTools(cwd)
const toolSet: ToolSet = {}

for (const [name, piTool] of Object.entries(piTools)) {
const prefixedName = `filesystem_${name}`
for (const def of ALL_TOOLS) {
const prefixedName = `filesystem_${def.name}`

toolSet[prefixedName] = tool({
description: piTool.description,
inputSchema: jsonSchema(
JSON.parse(JSON.stringify(piTool.parameters)) as Parameters<
typeof jsonSchema
>[0],
),
description: def.description,
inputSchema: def.input,
execute: async (params) => {
const startTime = performance.now()
try {
const result = await piTool.execute(crypto.randomUUID(), params)
const result = await def.execute(params, cwd)

metrics.log('tool_executed', {
tool_name: prefixedName,
duration_ms: Math.round(performance.now() - startTime),
success: true,
success: !result.isError,
})

return { content: result.content, isError: false }
return { content: result.content, isError: result.isError ?? false }
} catch (error) {
const errorText =
error instanceof Error ? error.message : String(error)
Expand All @@ -108,14 +92,13 @@ export function buildFilesystemToolSet(cwd: string): ToolSet {
},
toModelOutput: ({ output }) => {
const result = output as {
content: PiContent
content: ContentItem[]
isError: boolean
}
if (result.isError) {
const text = result.content
.filter(
(c): c is PiContent[number] & { type: 'text' } =>
c.type === 'text',
(c): c is ContentItem & { type: 'text' } => c.type === 'text',
)
.map((c) => c.text)
.join('\n')
Expand All @@ -124,7 +107,7 @@ export function buildFilesystemToolSet(cwd: string): ToolSet {
if (!result.content?.length) {
return { type: 'text', value: 'Success' }
}
return piContentToModelOutput(result.content)
return contentToModelOutput(result.content)
},
})
}
Expand Down
Loading
Loading