ππ Agent
Chapter 5 / 21

Built-in Tools

Four default tools; the rest is extension territory

Operations interfaces behind every tool; mutations serialized
No simulation scenario available for this chapter

Deep Dive

~7 min read·6611 chars

Four default tools; everything else is extension territory

Learning Objectives

After this chapter you will be able to:

  • Explain how the Operations interface makes tools "wrappable"
  • Describe what concurrency problem withFileMutationQueue solves
  • Explain edit-diff's dual role (user review + model feedback)
  • Understand why pi ships only 4 built-in tools instead of 20

Key Source Files

FileLinesResponsibility
packages/coding-agent/src/core/tools/read.ts~120Read-only file/directory access
packages/coding-agent/src/core/tools/bash.ts505Shell command execution + session env injection
packages/coding-agent/src/core/tools/edit.ts437Precise replacement editing
packages/coding-agent/src/core/tools/edit-diff.ts560Unified diff/patch generation
packages/coding-agent/src/core/tools/file-mutation-queue.ts~80Same-file write serialization

Design Motivation: Why Only 4?

Problem: Competitors (Cursor, Windsurf) ship 15-30 built-in tools. Why does pi only provide 4?

pi's philosophy: "Minimal core + self-extension" (→ Chapter 13). Built-in tools only cover operations that cannot be safely implemented by extensions:

  • read: Extensions can read, but the built-in has unified line numbers/truncation/binary detection
  • bash: Must be built-in because spawn permission control is needed
  • edit: Must be built-in because mutation queue serialization is required
  • write: Same as edit — needs queue protection

Everything else (search, browser, database, API calls) → extension territory. The smaller the kernel, the narrower the security audit surface.

Operations Interface: The Tool's Extension Point

// Tools don't touch fs directly — they go through injected Operations
interface EditOperations {
  read(path: string): Promise<string>
  write(path: string, content: string): Promise<void>
}

interface BashOperations {
  spawn(cmd: string, opts: SpawnOptions): ChildProcess
}

// Tool factory parameterized over Operations
export function createEditTool(ops: EditOperations): AgentTool {
  return defineTool({
    name: "edit",
    params: Type.Object({
      path: Type.String(),
      old: Type.String(),
      new: Type.String(),
    }),
    execute: (args, ctx) => withFileMutationQueue(args.path, async () => {
      const current = await ops.read(args.path)
      const next = current.replace(args.old, args.new)
      await ops.write(args.path, next)
      return { patch: generateUnifiedPatch(args.path, current, next) }
    }),
  })
}

Why inject instead of directly importing fs?

  • Extensions can wrap Operations: add audit logs, forward to sandbox, record screenshots
  • Testing can mock: no real filesystem needed
  • Server mode can replace with remote fs (→ Chapter 19 RPC)

withFileMutationQueue: Serializing Writes

Problem: The model may call edit("auth.ts", ...) and edit("auth.ts", ...) in one turn. If executed in parallel:

Parallel disaster:
  edit-1 reads auth.ts (v1) → modifies → writes auth.ts (v2)
  edit-2 reads auth.ts (v1) → modifies → writes auth.ts (v2')  ← overwrites edit-1's change!

Solution: Per-file mutex queue

const queues = new Map<string, Promise<void>>()

export function withFileMutationQueue<T>(path: string, fn: () => Promise<T>): Promise<T> {
  const prev = queues.get(path) ?? Promise.resolve()
  const next = prev.then(fn)
  queues.set(path, next.catch(() => {}))  // errors don't block subsequent
  return next
}

Same-file operations are strictly serialized; different files can still run in parallel. The result is predictable: "last edit finishes before this one starts."

edit-diff: Dual Role

edit-diff.ts (560 lines) generates unified diff/patch serving two consumers:

  1. User review: TUI displays colored diff; user confirms whether the change is reasonable
  2. Model feedback: Patch written back to messages as tool_result; model knows "what changed"
edit tool_result:
{
  "patch": "--- a/src/auth.ts\n+++ b/src/auth.ts\n@@ -3 +3 @@\n-  if (pw === pw)\n+  if (await bcrypt.compare(pw, hash))",
  "matches": 1
}

After seeing the patch, the model can judge: was the right location changed? Are further modifications needed?

bash Tool: Session Env Injection

bash.ts is more than child_process.spawn — it injects session context:

const env = {
  ...process.env,
  PI_SESSION_ID: session.id,       // child process knows its session
  PI_MODEL: model.id,              // knows the current model
  PI_REASONING_LEVEL: thinking,    // knows reasoning depth
}

This lets users' shell scripts do conditional logic based on session context.

Engineering Insights

  1. TypeBox's dual purpose: Parameter schemas do runtime validation (rejecting invalid args the model hallucinated) AND auto-generate JSON Schema fed to the model (so it knows how to call the tool).

  2. bash's 505-line cost: Most is safety logic — timeout kills, output truncation, working directory restrictions, dangerous command detection. spawn itself is 3 lines.

  3. find/grep/ls exist but aren't default: They're in the code but not registered by default. Because the model can use bash("grep ...") to achieve the same — fewer built-ins means a narrower maintenance surface.

Cross-Chapter Links

DirectionChapterRelationship
PrerequisiteCh01 Agent LoopTools execute in the loop's executeTools phase
NextCh13 Extension SystemNon-built-in tools register via extensions
NextCh18 Settings & TrustTool permissions gated by Trust
NextCh19 RPC ServerServer mode executes tools remotely via RPC

Check Your Understanding

  1. If withFileMutationQueue were removed and two edits to the same file ran in parallel, what's the worst case?
  2. Why do tools operate on files through the Operations interface rather than directly import { readFileSync } from "fs"?
  3. Why doesn't pi make grep/find/ls default built-in tools? What design principle does this reflect?

Key Takeaways

  • Default tools are minimal (read/bash/edit/write) — only what extensions can't safely provide
  • Operations interface lets extensions wrap everything: audit, sandbox, remote
  • Mutation queue serializes same-file writes, preventing concurrent overwrites
  • TypeBox schema kills two birds: runtime validation + model JSON Schema

This article is based on source analysis of earendil-works/pi main branch.