feat(coding-agent): type ToolCallEvent.input per tool (#1147)

This commit is contained in:
Mario Zechner 2026-02-01 19:13:55 +01:00
commit ce607fc343
13 changed files with 219 additions and 19 deletions

View file

@ -4,6 +4,7 @@
### Added
- Added typed `ToolCallEvent.input` per tool with `isToolCallEventType()` type guard for narrowing built-in tool events ([#1147](https://github.com/badlogic/pi-mono/pull/1147) by [@giuseppeg](https://github.com/giuseppeg))
- Exported `discoverAndLoadExtensions` from package to enable extension testing without a local repo clone ([#1148](https://github.com/badlogic/pi-mono/issues/1148))
- Added Extension UI Protocol documentation to RPC docs covering all request/response types for extension dialogs and notifications ([#1144](https://github.com/badlogic/pi-mono/pull/1144) by [@aliou](https://github.com/aliou))
- Added `rpc-demo.ts` example extension exercising all RPC-supported extension UI methods ([#1144](https://github.com/badlogic/pi-mono/pull/1144) by [@aliou](https://github.com/aliou))

View file

@ -473,16 +473,49 @@ Use this to update UI elements (status bars, footers) or perform model-specific
#### tool_call
Fired before tool executes. **Can block.**
Fired before tool executes. **Can block.** Use `isToolCallEventType` to narrow and get typed inputs.
```typescript
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
pi.on("tool_call", async (event, ctx) => {
// event.toolName - "bash", "read", "write", "edit", etc.
// event.toolCallId
// event.input - tool parameters
if (shouldBlock(event)) {
return { block: true, reason: "Not allowed" };
// Built-in tools: no type params needed
if (isToolCallEventType("bash", event)) {
// event.input is { command: string; timeout?: number }
if (event.input.command.includes("rm -rf")) {
return { block: true, reason: "Dangerous command" };
}
}
if (isToolCallEventType("read", event)) {
// event.input is { path: string; offset?: number; limit?: number }
console.log(`Reading: ${event.input.path}`);
}
});
```
#### Typing custom tool input
Custom tools should export their input type:
```typescript
// my-extension.ts
export type MyToolInput = Static<typeof myToolSchema>;
```
Use `isToolCallEventType` with explicit type parameters:
```typescript
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
import type { MyToolInput } from "my-extension";
pi.on("tool_call", (event) => {
if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) {
event.input.action; // typed
}
});
```

View file

@ -25,6 +25,8 @@ export type {
// App keybindings (for custom editors)
AppAction,
AppendEntryHandler,
// Events - Tool (ToolCallEvent types)
BashToolCallEvent,
BashToolResultEvent,
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
@ -35,7 +37,9 @@ export type {
// Event Results
ContextEventResult,
ContextUsage,
CustomToolCallEvent,
CustomToolResultEvent,
EditToolCallEvent,
EditToolResultEvent,
ExecOptions,
ExecResult,
@ -59,10 +63,12 @@ export type {
ExtensionUIContext,
ExtensionUIDialogOptions,
ExtensionWidgetOptions,
FindToolCallEvent,
FindToolResultEvent,
GetActiveToolsHandler,
GetAllToolsHandler,
GetThinkingLevelHandler,
GrepToolCallEvent,
GrepToolResultEvent,
// Events - Input
InputEvent,
@ -70,6 +76,7 @@ export type {
InputSource,
KeybindingsManager,
LoadExtensionsResult,
LsToolCallEvent,
LsToolResultEvent,
// Message Rendering
MessageRenderer,
@ -79,6 +86,7 @@ export type {
// Provider Registration
ProviderConfig,
ProviderModelConfig,
ReadToolCallEvent,
ReadToolResultEvent,
// Commands
RegisteredCommand,
@ -124,6 +132,7 @@ export type {
UserBashEvent,
UserBashEventResult,
WidgetPlacement,
WriteToolCallEvent,
WriteToolResultEvent,
} from "./types.js";
// Type guards
@ -134,6 +143,7 @@ export {
isGrepToolResult,
isLsToolResult,
isReadToolResult,
isToolCallEventType,
isWriteToolResult,
} from "./types.js";
export {

View file

@ -57,10 +57,17 @@ import type { BashOperations } from "../tools/bash.js";
import type { EditToolDetails } from "../tools/edit.js";
import type {
BashToolDetails,
BashToolInput,
EditToolInput,
FindToolDetails,
FindToolInput,
GrepToolDetails,
GrepToolInput,
LsToolDetails,
LsToolInput,
ReadToolDetails,
ReadToolInput,
WriteToolInput,
} from "../tools/index.js";
export type { ExecOptions, ExecResult } from "../exec.js";
@ -547,14 +554,62 @@ export type InputEventResult =
// Tool Events
// ============================================================================
/** Fired before a tool executes. Can block. */
export interface ToolCallEvent {
interface ToolCallEventBase {
type: "tool_call";
toolName: string;
toolCallId: string;
}
export interface BashToolCallEvent extends ToolCallEventBase {
toolName: "bash";
input: BashToolInput;
}
export interface ReadToolCallEvent extends ToolCallEventBase {
toolName: "read";
input: ReadToolInput;
}
export interface EditToolCallEvent extends ToolCallEventBase {
toolName: "edit";
input: EditToolInput;
}
export interface WriteToolCallEvent extends ToolCallEventBase {
toolName: "write";
input: WriteToolInput;
}
export interface GrepToolCallEvent extends ToolCallEventBase {
toolName: "grep";
input: GrepToolInput;
}
export interface FindToolCallEvent extends ToolCallEventBase {
toolName: "find";
input: FindToolInput;
}
export interface LsToolCallEvent extends ToolCallEventBase {
toolName: "ls";
input: LsToolInput;
}
export interface CustomToolCallEvent extends ToolCallEventBase {
toolName: string;
input: Record<string, unknown>;
}
/** Fired before a tool executes. Can block. */
export type ToolCallEvent =
| BashToolCallEvent
| ReadToolCallEvent
| EditToolCallEvent
| WriteToolCallEvent
| GrepToolCallEvent
| FindToolCallEvent
| LsToolCallEvent
| CustomToolCallEvent;
interface ToolResultEventBase {
type: "tool_result";
toolCallId: string;
@ -614,7 +669,7 @@ export type ToolResultEvent =
| LsToolResultEvent
| CustomToolResultEvent;
// Type guards
// Type guards for ToolResultEvent
export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent {
return e.toolName === "bash";
}
@ -637,6 +692,41 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
return e.toolName === "ls";
}
/**
* Type guard for narrowing ToolCallEvent by tool name.
*
* Built-in tools narrow automatically (no type params needed):
* ```ts
* if (isToolCallEventType("bash", event)) {
* event.input.command; // string
* }
* ```
*
* Custom tools require explicit type parameters:
* ```ts
* if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) {
* event.input.action; // typed
* }
* ```
*
* Note: Direct narrowing via `event.toolName === "bash"` doesn't work because
* CustomToolCallEvent.toolName is `string` which overlaps with all literals.
*/
export function isToolCallEventType(toolName: "bash", event: ToolCallEvent): event is BashToolCallEvent;
export function isToolCallEventType(toolName: "read", event: ToolCallEvent): event is ReadToolCallEvent;
export function isToolCallEventType(toolName: "edit", event: ToolCallEvent): event is EditToolCallEvent;
export function isToolCallEventType(toolName: "write", event: ToolCallEvent): event is WriteToolCallEvent;
export function isToolCallEventType(toolName: "grep", event: ToolCallEvent): event is GrepToolCallEvent;
export function isToolCallEventType(toolName: "find", event: ToolCallEvent): event is FindToolCallEvent;
export function isToolCallEventType(toolName: "ls", event: ToolCallEvent): event is LsToolCallEvent;
export function isToolCallEventType<TName extends string, TInput extends Record<string, unknown>>(
toolName: TName,
event: ToolCallEvent,
): event is ToolCallEvent & { toolName: TName; input: TInput };
export function isToolCallEventType(toolName: string, event: ToolCallEvent): boolean {
return event.toolName === toolName;
}
/** Union of all event types */
export type ExtensionEvent =
| ResourcesDiscoverEvent

View file

@ -3,7 +3,7 @@ import { createWriteStream, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { type Static, Type } from "@sinclair/typebox";
import { spawn } from "child_process";
import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
@ -21,6 +21,8 @@ const bashSchema = Type.Object({
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
});
export type BashToolInput = Static<typeof bashSchema>;
export interface BashToolDetails {
truncation?: TruncationResult;
fullOutputPath?: string;

View file

@ -1,5 +1,5 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { type Static, Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
import {
@ -19,6 +19,8 @@ const editSchema = Type.Object({
newText: Type.String({ description: "New text to replace the old text with" }),
});
export type EditToolInput = Static<typeof editSchema>;
export interface EditToolDetails {
/** Unified diff of the changes made */
diff: string;

View file

@ -1,5 +1,5 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { type Static, Type } from "@sinclair/typebox";
import { spawnSync } from "child_process";
import { existsSync } from "fs";
import { globSync } from "glob";
@ -16,6 +16,8 @@ const findSchema = Type.Object({
limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })),
});
export type FindToolInput = Static<typeof findSchema>;
const DEFAULT_LIMIT = 1000;
export interface FindToolDetails {

View file

@ -1,6 +1,6 @@
import { createInterface } from "node:readline";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { type Static, Type } from "@sinclair/typebox";
import { spawn } from "child_process";
import { readFileSync, statSync } from "fs";
import path from "path";
@ -29,6 +29,8 @@ const grepSchema = Type.Object({
limit: Type.Optional(Type.Number({ description: "Maximum number of matches to return (default: 100)" })),
});
export type GrepToolInput = Static<typeof grepSchema>;
const DEFAULT_LIMIT = 100;
export interface GrepToolDetails {

View file

@ -1,18 +1,48 @@
export {
type BashOperations,
type BashToolDetails,
type BashToolInput,
type BashToolOptions,
bashTool,
createBashTool,
} from "./bash.js";
export { createEditTool, type EditOperations, type EditToolDetails, type EditToolOptions, editTool } from "./edit.js";
export { createFindTool, type FindOperations, type FindToolDetails, type FindToolOptions, findTool } from "./find.js";
export { createGrepTool, type GrepOperations, type GrepToolDetails, type GrepToolOptions, grepTool } from "./grep.js";
export { createLsTool, type LsOperations, type LsToolDetails, type LsToolOptions, lsTool } from "./ls.js";
export {
createEditTool,
type EditOperations,
type EditToolDetails,
type EditToolInput,
type EditToolOptions,
editTool,
} from "./edit.js";
export {
createFindTool,
type FindOperations,
type FindToolDetails,
type FindToolInput,
type FindToolOptions,
findTool,
} from "./find.js";
export {
createGrepTool,
type GrepOperations,
type GrepToolDetails,
type GrepToolInput,
type GrepToolOptions,
grepTool,
} from "./grep.js";
export {
createLsTool,
type LsOperations,
type LsToolDetails,
type LsToolInput,
type LsToolOptions,
lsTool,
} from "./ls.js";
export {
createReadTool,
type ReadOperations,
type ReadToolDetails,
type ReadToolInput,
type ReadToolOptions,
readTool,
} from "./read.js";
@ -26,7 +56,13 @@ export {
truncateLine,
truncateTail,
} from "./truncate.js";
export { createWriteTool, type WriteOperations, type WriteToolOptions, writeTool } from "./write.js";
export {
createWriteTool,
type WriteOperations,
type WriteToolInput,
type WriteToolOptions,
writeTool,
} from "./write.js";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { type BashToolOptions, bashTool, createBashTool } from "./bash.js";

View file

@ -1,5 +1,5 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { type Static, Type } from "@sinclair/typebox";
import { existsSync, readdirSync, statSync } from "fs";
import nodePath from "path";
import { resolveToCwd } from "./path-utils.js";
@ -10,6 +10,8 @@ const lsSchema = Type.Object({
limit: Type.Optional(Type.Number({ description: "Maximum number of entries to return (default: 500)" })),
});
export type LsToolInput = Static<typeof lsSchema>;
const DEFAULT_LIMIT = 500;
export interface LsToolDetails {

View file

@ -1,6 +1,6 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { type Static, Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile } from "fs/promises";
import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js";
@ -14,6 +14,8 @@ const readSchema = Type.Object({
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
});
export type ReadToolInput = Static<typeof readSchema>;
export interface ReadToolDetails {
truncation?: TruncationResult;
}

View file

@ -1,5 +1,5 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { type Static, Type } from "@sinclair/typebox";
import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises";
import { dirname } from "path";
import { resolveToCwd } from "./path-utils.js";
@ -9,6 +9,8 @@ const writeSchema = Type.Object({
content: Type.String({ description: "Content to write to the file" }),
});
export type WriteToolInput = Static<typeof writeSchema>;
/**
* Pluggable operations for the write tool.
* Override these to delegate file writing to remote systems (e.g., SSH).

View file

@ -46,10 +46,13 @@ export type {
AgentToolResult,
AgentToolUpdateCallback,
AppAction,
BashToolCallEvent,
BeforeAgentStartEvent,
CompactOptions,
ContextEvent,
ContextUsage,
CustomToolCallEvent,
EditToolCallEvent,
ExecOptions,
ExecResult,
Extension,
@ -69,15 +72,19 @@ export type {
ExtensionUIContext,
ExtensionUIDialogOptions,
ExtensionWidgetOptions,
FindToolCallEvent,
GrepToolCallEvent,
InputEvent,
InputEventResult,
InputSource,
KeybindingsManager,
LoadExtensionsResult,
LsToolCallEvent,
MessageRenderer,
MessageRenderOptions,
ProviderConfig,
ProviderModelConfig,
ReadToolCallEvent,
RegisteredCommand,
RegisteredTool,
SessionBeforeCompactEvent,
@ -100,6 +107,7 @@ export type {
UserBashEvent,
UserBashEventResult,
WidgetPlacement,
WriteToolCallEvent,
} from "./core/extensions/index.js";
export {
createExtensionRuntime,
@ -111,6 +119,7 @@ export {
isGrepToolResult,
isLsToolResult,
isReadToolResult,
isToolCallEventType,
isWriteToolResult,
wrapRegisteredTool,
wrapRegisteredTools,
@ -196,6 +205,7 @@ export {
export {
type BashOperations,
type BashToolDetails,
type BashToolInput,
type BashToolOptions,
bashTool,
codingTools,
@ -203,23 +213,28 @@ export {
DEFAULT_MAX_LINES,
type EditOperations,
type EditToolDetails,
type EditToolInput,
type EditToolOptions,
editTool,
type FindOperations,
type FindToolDetails,
type FindToolInput,
type FindToolOptions,
findTool,
formatSize,
type GrepOperations,
type GrepToolDetails,
type GrepToolInput,
type GrepToolOptions,
grepTool,
type LsOperations,
type LsToolDetails,
type LsToolInput,
type LsToolOptions,
lsTool,
type ReadOperations,
type ReadToolDetails,
type ReadToolInput,
type ReadToolOptions,
readTool,
type ToolsOptions,
@ -229,6 +244,7 @@ export {
truncateLine,
truncateTail,
type WriteOperations,
type WriteToolInput,
type WriteToolOptions,
writeTool,
} from "./core/tools/index.js";