@aliou/pi-dev-kit 0.6.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +15 -10
- package/src/commands/index.ts +5 -1
- package/src/commands/update.ts +139 -91
- package/src/skills/pi-extension/SKILL.md +108 -131
- package/src/skills/pi-extension/references/additional-apis.md +256 -173
- package/src/skills/pi-extension/references/commands.md +113 -33
- package/src/skills/pi-extension/references/components.md +267 -102
- package/src/skills/pi-extension/references/hooks.md +229 -156
- package/src/skills/pi-extension/references/messages.md +97 -92
- package/src/skills/pi-extension/references/modes.md +80 -90
- package/src/skills/pi-extension/references/providers.md +255 -96
- package/src/skills/pi-extension/references/publish.md +76 -62
- package/src/skills/pi-extension/references/state.md +80 -33
- package/src/skills/pi-extension/references/structure.md +156 -245
- package/src/skills/pi-extension/references/testing.md +1 -1
- package/src/skills/pi-extension/references/tools.md +212 -816
- package/src/tools/changelog-tool.ts +237 -230
- package/src/tools/docs-tool.ts +127 -130
- package/src/tools/index.ts +5 -1
- package/src/tools/package-manager-tool.ts +152 -147
- package/src/tools/utils.ts +33 -23
- package/src/tools/version-tool.ts +51 -51
- package/src/index.ts +0 -8
|
@@ -1,274 +1,174 @@
|
|
|
1
1
|
# Tools
|
|
2
2
|
|
|
3
|
-
Tools are functions the LLM can call. They are the
|
|
3
|
+
Tools are functions the LLM can call. They are the main way extensions add capabilities to Pi.
|
|
4
4
|
|
|
5
5
|
## Imports
|
|
6
6
|
|
|
7
|
-
Use
|
|
7
|
+
Use the namespace that matches the target Pi package. Examples use the forward namespace.
|
|
8
8
|
|
|
9
9
|
```typescript
|
|
10
|
-
import {
|
|
11
|
-
import type {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import {
|
|
10
|
+
import { ToolBody, ToolCallHeader, ToolFooter } from "@aliou/pi-utils-ui";
|
|
11
|
+
import type { AgentToolResult, ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import {
|
|
13
|
+
defineTool,
|
|
14
|
+
formatSize,
|
|
15
|
+
getMarkdownTheme,
|
|
16
|
+
keyHint,
|
|
17
|
+
truncateHead,
|
|
18
|
+
truncateTail,
|
|
19
|
+
withFileMutationQueue,
|
|
20
|
+
} from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
22
|
+
import { Container, Markdown, Text } from "@earendil-works/pi-tui";
|
|
23
|
+
import { type Static, Type } from "typebox";
|
|
22
24
|
```
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
Use legacy `@mariozechner/*` imports only when the target `@earendil-works/*` package is not available yet.
|
|
27
|
+
|
|
28
|
+
## Minimal Tool Entry Point
|
|
29
|
+
|
|
30
|
+
Tool entry points are normal Pi extension entry points. Export a default function and list the file in `package.json` `pi.extensions`.
|
|
25
31
|
|
|
26
32
|
```typescript
|
|
27
|
-
const
|
|
33
|
+
const parameters = Type.Object({
|
|
34
|
+
query: Type.String({ description: "Search query" }),
|
|
35
|
+
limit: Type.Optional(Type.Number({ description: "Max results", default: 10 })),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
type MyToolParams = Static<typeof parameters>;
|
|
39
|
+
|
|
40
|
+
interface MyToolDetails {
|
|
41
|
+
results: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const myTool = defineTool({
|
|
28
45
|
name: "my_tool",
|
|
29
|
-
label: "My Tool",
|
|
30
|
-
description: "
|
|
31
|
-
promptSnippet: "Search for items by query",
|
|
32
|
-
promptGuidelines: [
|
|
33
|
-
"Use my_tool when the user asks
|
|
34
|
-
"Prefer specific queries
|
|
46
|
+
label: "My Tool",
|
|
47
|
+
description: "Search for items by query.",
|
|
48
|
+
promptSnippet: "Search for items by query",
|
|
49
|
+
promptGuidelines: [
|
|
50
|
+
"Use my_tool when the user asks to search these items.",
|
|
51
|
+
"Prefer specific queries when calling my_tool.",
|
|
35
52
|
],
|
|
36
|
-
parameters
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
async execute(
|
|
42
|
-
toolCallId: string,
|
|
43
|
-
params: MyToolParams,
|
|
44
|
-
signal: AbortSignal | undefined,
|
|
45
|
-
onUpdate: AgentToolUpdateCallback<MyToolDetails> | undefined,
|
|
46
|
-
ctx: ExtensionContext,
|
|
47
|
-
): Promise<AgentToolResult<MyToolDetails>> {
|
|
48
|
-
const results = await doSomething(params.query, params.limit);
|
|
53
|
+
parameters,
|
|
54
|
+
|
|
55
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx): Promise<AgentToolResult<MyToolDetails>> {
|
|
56
|
+
onUpdate?.({ content: [{ type: "text", text: "Searching..." }] });
|
|
57
|
+
const results = await searchItems(params.query, { limit: params.limit, signal });
|
|
49
58
|
return {
|
|
50
59
|
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
51
60
|
details: { results },
|
|
52
61
|
};
|
|
53
62
|
},
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
// Typed param alias - define once, use everywhere
|
|
57
|
-
type MyToolParams = Static<typeof myTool.parameters>;
|
|
58
|
-
interface MyToolDetails {
|
|
59
|
-
results: string[];
|
|
60
|
-
}
|
|
63
|
+
});
|
|
61
64
|
|
|
62
|
-
export default function (pi: ExtensionAPI) {
|
|
65
|
+
export default function toolsExtension(pi: ExtensionAPI) {
|
|
63
66
|
pi.registerTool(myTool);
|
|
64
67
|
}
|
|
65
68
|
```
|
|
66
69
|
|
|
67
|
-
##
|
|
70
|
+
## `defineTool()`
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|-------|------|----------|-------------|
|
|
71
|
-
| `name` | `string` | Yes | Snake_case identifier used in tool calls |
|
|
72
|
-
| `label` | `string` | Yes | Human-readable name shown in UI |
|
|
73
|
-
| `description` | `string` | Yes | What the tool does (LLM reads this) |
|
|
74
|
-
| `parameters` | `TSchema` | Yes | TypeBox schema for arguments |
|
|
75
|
-
| `promptSnippet` | `string` | No | One-liner injected into "Available tools" system prompt. Custom tools without this are omitted from that section. |
|
|
76
|
-
| `promptGuidelines` | `string[]` | No | Guideline bullets appended verbatim to the global "Guidelines" system prompt section when this tool is active. Write each bullet so it still makes sense standalone. |
|
|
77
|
-
| `execute` | `function` | Yes | Implementation |
|
|
78
|
-
| `renderCall` | `function` | No | Custom call rendering |
|
|
79
|
-
| `renderResult` | `function` | No | Custom result rendering |
|
|
72
|
+
Use `defineTool({...})` for standalone tool objects. It preserves parameter inference from the `parameters` field when the tool is stored in a variable or passed through arrays.
|
|
80
73
|
|
|
81
|
-
|
|
74
|
+
Rules:
|
|
82
75
|
|
|
83
|
-
|
|
76
|
+
- Do not pass explicit generic arguments to `defineTool`.
|
|
77
|
+
- Do not annotate callback parameters unless TypeScript needs help.
|
|
78
|
+
- Add a `Static<typeof parameters>` alias for helper functions and action modules.
|
|
79
|
+
- If you want `renderResult` to know the `details` shape, annotate `execute` with `Promise<AgentToolResult<MyDetails>>`.
|
|
84
80
|
|
|
85
|
-
|
|
86
|
-
const parameters = Type.Object({
|
|
87
|
-
query: Type.String({ description: "Search query" }),
|
|
88
|
-
limit: Type.Optional(Type.Number({ description: "Max results", default: 10 })),
|
|
89
|
-
});
|
|
81
|
+
## Tool Definition Fields
|
|
90
82
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
83
|
+
| Field | Required | Notes |
|
|
84
|
+
|---|---:|---|
|
|
85
|
+
| `name` | Yes | Snake_case identifier used in tool calls. |
|
|
86
|
+
| `label` | Yes | Human-readable display name. |
|
|
87
|
+
| `description` | Yes | Model-facing description. |
|
|
88
|
+
| `parameters` | Yes | TypeBox 1.x schema from `typebox`. |
|
|
89
|
+
| `execute` | Yes | `(toolCallId, params, signal, onUpdate, ctx)`. |
|
|
90
|
+
| `promptSnippet` | No | One-line entry in Available tools. Custom tools are omitted there when absent. |
|
|
91
|
+
| `promptGuidelines` | No | Raw bullets appended to the global Guidelines section. Each bullet must name the tool. |
|
|
92
|
+
| `prepareArguments` | No | Compatibility shim before schema validation. |
|
|
93
|
+
| `executionMode` | No | Use `"sequential"` for shared-state tools that cannot run concurrently. |
|
|
94
|
+
| `renderCall` / `renderResult` | No | Custom TUI component renderers. |
|
|
95
|
+
| `renderShell` | No | Use `"self"` only when the default boxed shell gets in the way. |
|
|
94
96
|
|
|
95
97
|
## Execute Signature
|
|
96
98
|
|
|
97
99
|
```typescript
|
|
98
|
-
execute(
|
|
99
|
-
toolCallId: string,
|
|
100
|
-
params: Static<TParams>, // Typed from the parameters schema
|
|
101
|
-
signal: AbortSignal | undefined,
|
|
102
|
-
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
|
|
103
|
-
ctx: ExtensionContext,
|
|
104
|
-
): Promise<AgentToolResult<TDetails>>
|
|
100
|
+
async execute(toolCallId, params, signal, onUpdate, ctx): Promise<AgentToolResult<TDetails>>
|
|
105
101
|
```
|
|
106
102
|
|
|
107
|
-
|
|
103
|
+
Parameter order matters. `signal` comes before `onUpdate`.
|
|
108
104
|
|
|
109
|
-
|
|
105
|
+
Use optional chaining for updates:
|
|
110
106
|
|
|
111
107
|
```typescript
|
|
112
|
-
onUpdate?.({
|
|
108
|
+
onUpdate?.({
|
|
109
|
+
content: [{ type: "text", text: "Working..." }],
|
|
110
|
+
details: { progress: 50 },
|
|
111
|
+
});
|
|
113
112
|
```
|
|
114
113
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
## Tool Overrides and Delegation
|
|
118
|
-
|
|
119
|
-
If you override a built-in tool or wrap another tool, audit any delegated `tool.execute(...)` calls during upgrades. These forwarders often pass through `signal`, `onUpdate`, or `ctx` and can silently break when the execute signature changes. Always recheck the delegate call parameter order and include optional parameters that the target tool expects.
|
|
120
|
-
|
|
121
|
-
Prompt metadata is not inherited automatically when you override a built-in tool. If the original tool had `promptSnippet` or `promptGuidelines` and you still want that system prompt behavior, define those fields explicitly on the override.
|
|
122
|
-
|
|
123
|
-
## Return Value
|
|
114
|
+
Forward `signal` to all abort-aware work:
|
|
124
115
|
|
|
125
116
|
```typescript
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
details?: TDetails, // Arbitrary data available in the renderer
|
|
129
|
-
};
|
|
117
|
+
const response = await fetch(url, { signal });
|
|
118
|
+
const result = await pi.exec("git", ["status", "--porcelain"], { signal, cwd: ctx.cwd });
|
|
130
119
|
```
|
|
131
120
|
|
|
132
|
-
|
|
133
|
-
- `details` is what the renderer sees. Put rich data here for custom display.
|
|
121
|
+
Do not use Node `child_process` for normal commands. Use `pi.exec(command, args, options)` so Pi handles CWD, cancellation, output capture, and lifecycle integration.
|
|
134
122
|
|
|
135
|
-
|
|
123
|
+
## Return Value and Errors
|
|
136
124
|
|
|
137
125
|
```typescript
|
|
138
126
|
return {
|
|
139
|
-
content: [{ type: "text", text:
|
|
140
|
-
details: {
|
|
127
|
+
content: [{ type: "text", text: "Text sent to the LLM" }],
|
|
128
|
+
details: { rich: "data for rendering and state" },
|
|
129
|
+
terminate: true, // optional final/structured-output tools only
|
|
141
130
|
};
|
|
142
131
|
```
|
|
143
132
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
```typescript
|
|
149
|
-
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
150
|
-
const result = await fetchData(params.query);
|
|
151
|
-
if (!result) {
|
|
152
|
-
throw new Error("No results found. Try a different query.");
|
|
153
|
-
}
|
|
154
|
-
return {
|
|
155
|
-
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
156
|
-
details: { result },
|
|
157
|
-
};
|
|
158
|
-
},
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
The framework's `createErrorToolResult` sets `details: {}` (empty object, not `undefined`) and puts the error message in `content`:
|
|
162
|
-
|
|
163
|
-
```typescript
|
|
164
|
-
// Framework produces when tool throws:
|
|
165
|
-
{
|
|
166
|
-
content: [{ type: "text", text: errorMessage }],
|
|
167
|
-
details: {}
|
|
168
|
-
}
|
|
169
|
-
// And sets context.isError = true in renderResult
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
### Error rendering in `renderResult`
|
|
173
|
-
|
|
174
|
-
The framework passes `isError` on the 4th `context` parameter to `renderResult`, but `ToolRenderContext` is not currently exported from the public API. Two practical approaches:
|
|
175
|
-
|
|
176
|
-
**Approach 1: Check for missing expected fields in `details`** (recommended for extensions)
|
|
177
|
-
|
|
178
|
-
When a tool throws, the framework sets `details: {}` (empty object). Check for missing expected fields:
|
|
179
|
-
|
|
180
|
-
```typescript
|
|
181
|
-
renderResult(
|
|
182
|
-
result: AgentToolResult<MyToolDetails>,
|
|
183
|
-
options: ToolRenderResultOptions,
|
|
184
|
-
theme: Theme,
|
|
185
|
-
) {
|
|
186
|
-
const { details } = result;
|
|
187
|
-
|
|
188
|
-
// details is {} when tool threw — expected fields are missing
|
|
189
|
-
if (!details?.results) {
|
|
190
|
-
const textBlock = result.content.find((c) => c.type === "text");
|
|
191
|
-
const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Operation failed";
|
|
192
|
-
return new Text(theme.fg("error", errorMsg), 0, 0);
|
|
193
|
-
}
|
|
194
|
-
// ... normal rendering
|
|
195
|
-
}
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
**Approach 2: Use the 4th context parameter** (used by native tools)
|
|
133
|
+
- `content` is sent to the LLM. Keep it concise and useful.
|
|
134
|
+
- `details` is persisted in the session and used by renderers. Put state snapshots here when the tool mutates extension state.
|
|
135
|
+
- `terminate: true` skips the automatic follow-up LLM call only when every finalized tool in the current batch also terminates.
|
|
136
|
+
- Throw an error to mark a tool result as failed. Returning an object never sets `isError`.
|
|
199
137
|
|
|
200
|
-
|
|
138
|
+
When a tool throws, Pi creates an error result with `details: {}` and the error message in `content`. Renderers should check for missing expected fields or use the 4th render context's `isError` field.
|
|
201
139
|
|
|
202
|
-
|
|
203
|
-
interface RenderContext { isError: boolean }
|
|
204
|
-
|
|
205
|
-
renderResult(
|
|
206
|
-
result: AgentToolResult<MyToolDetails>,
|
|
207
|
-
options: ToolRenderResultOptions,
|
|
208
|
-
theme: Theme,
|
|
209
|
-
context: RenderContext,
|
|
210
|
-
) {
|
|
211
|
-
if (context.isError) {
|
|
212
|
-
const textBlock = result.content.find((c) => c.type === "text");
|
|
213
|
-
const errorMsg = textBlock?.type === "text" ? textBlock.text : "Operation failed";
|
|
214
|
-
return new Text(theme.fg("error", errorMsg), 0, 0);
|
|
215
|
-
}
|
|
216
|
-
// ... normal rendering
|
|
217
|
-
}
|
|
218
|
-
```
|
|
140
|
+
## Parameters
|
|
219
141
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
## Parameters Schema
|
|
223
|
-
|
|
224
|
-
Use TypeBox (`Type.*`) for parameter schemas. The LLM sees the schema to know what arguments to provide.
|
|
142
|
+
Use TypeBox 1.x from `typebox`. Always add useful descriptions.
|
|
225
143
|
|
|
226
144
|
```typescript
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
Type.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
Type.Optional(Type.
|
|
234
|
-
|
|
235
|
-
// Enum (string union)
|
|
236
|
-
Type.StringEnum(["created", "updated", "relevance"], { description: "Sort order" })
|
|
237
|
-
|
|
238
|
-
// Boolean
|
|
239
|
-
Type.Boolean({ description: "Include hidden files" })
|
|
240
|
-
|
|
241
|
-
// Nested object
|
|
242
|
-
Type.Object({
|
|
243
|
-
name: Type.String(),
|
|
244
|
-
value: Type.String(),
|
|
245
|
-
})
|
|
246
|
-
|
|
247
|
-
// Array
|
|
248
|
-
Type.Array(Type.String(), { description: "List of tags" })
|
|
145
|
+
const parameters = Type.Object({
|
|
146
|
+
path: Type.String({ description: "File path" }),
|
|
147
|
+
includeHidden: Type.Optional(Type.Boolean({ description: "Include hidden files", default: false })),
|
|
148
|
+
sort: Type.Optional(StringEnum(["created", "updated", "relevance"] as const, {
|
|
149
|
+
description: "Sort order",
|
|
150
|
+
})),
|
|
151
|
+
tags: Type.Optional(Type.Array(Type.String({ description: "Tag" }))),
|
|
152
|
+
});
|
|
249
153
|
```
|
|
250
154
|
|
|
251
|
-
|
|
155
|
+
Use `StringEnum` from `pi-ai` for model-facing string enums. Avoid `Type.Union([Type.Literal(...)])`; it is not compatible with all providers.
|
|
252
156
|
|
|
253
157
|
## Prompt Metadata
|
|
254
158
|
|
|
255
|
-
`promptSnippet` and `promptGuidelines` affect different
|
|
256
|
-
|
|
257
|
-
- `promptSnippet` adds a one-line entry to `Available tools`.
|
|
258
|
-
- `promptGuidelines` appends raw bullets to the global `Guidelines` section.
|
|
159
|
+
`promptSnippet` and `promptGuidelines` affect different system prompt sections:
|
|
259
160
|
|
|
260
|
-
|
|
161
|
+
- `promptSnippet` adds one line under Available tools.
|
|
162
|
+
- `promptGuidelines` appends raw bullets to the global Guidelines section when the tool is active.
|
|
261
163
|
|
|
262
|
-
|
|
263
|
-
- Write bullets so they still make sense when read out of context.
|
|
264
|
-
- Prefer explicit tool names over phrases like `this tool`.
|
|
164
|
+
Because `promptGuidelines` are not grouped under the tool, each bullet must name the exact tool.
|
|
265
165
|
|
|
266
166
|
Good:
|
|
267
167
|
|
|
268
168
|
```typescript
|
|
269
169
|
promptGuidelines: [
|
|
270
|
-
"Use
|
|
271
|
-
"
|
|
170
|
+
"Use repo_tree to inspect repository structure before reading individual files.",
|
|
171
|
+
"Start repo_tree at the root path, then drill into directories as needed.",
|
|
272
172
|
]
|
|
273
173
|
```
|
|
274
174
|
|
|
@@ -276,16 +176,16 @@ Weak:
|
|
|
276
176
|
|
|
277
177
|
```typescript
|
|
278
178
|
promptGuidelines: [
|
|
279
|
-
"Use this tool
|
|
280
|
-
"
|
|
179
|
+
"Use this tool before reading files.",
|
|
180
|
+
"Start at the root path.",
|
|
281
181
|
]
|
|
282
182
|
```
|
|
283
183
|
|
|
284
|
-
Use
|
|
184
|
+
Use per-tool metadata for simple guidance. Use a `before_agent_start` hook for dynamic config, cross-tool workflows, or complex orchestration.
|
|
285
185
|
|
|
286
|
-
## Argument Compatibility
|
|
186
|
+
## Argument Compatibility
|
|
287
187
|
|
|
288
|
-
Use `prepareArguments(args)`
|
|
188
|
+
Use `prepareArguments(args)` for backward-compatible migrations of old session tool-call shapes. It runs before validation. Keep the public schema strict.
|
|
289
189
|
|
|
290
190
|
```typescript
|
|
291
191
|
prepareArguments(args) {
|
|
@@ -298,21 +198,18 @@ prepareArguments(args) {
|
|
|
298
198
|
}
|
|
299
199
|
```
|
|
300
200
|
|
|
301
|
-
|
|
201
|
+
## Paths and File Mutation
|
|
202
|
+
|
|
203
|
+
If a tool accepts file paths, strip a leading `@` before resolving. Built-in tools do this because models sometimes include `@` from path mentions.
|
|
302
204
|
|
|
303
205
|
```typescript
|
|
304
206
|
const normalizedPath = params.path.startsWith("@") ? params.path.slice(1) : params.path;
|
|
207
|
+
const absolutePath = resolve(ctx.cwd, normalizedPath);
|
|
305
208
|
```
|
|
306
209
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
Tool calls can run in parallel. If your custom tool mutates files, use `withFileMutationQueue()` so it participates in the same per-file queue as built-in `edit` and `write`.
|
|
210
|
+
If a custom tool mutates files, wrap the whole read-modify-write window in `withFileMutationQueue()` so sibling parallel tool calls cannot overwrite each other.
|
|
310
211
|
|
|
311
212
|
```typescript
|
|
312
|
-
import { withFileMutationQueue } from "@mariozechner/pi-coding-agent";
|
|
313
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
314
|
-
import { dirname, resolve } from "node:path";
|
|
315
|
-
|
|
316
213
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
317
214
|
const normalizedPath = params.path.startsWith("@") ? params.path.slice(1) : params.path;
|
|
318
215
|
const absolutePath = resolve(ctx.cwd, normalizedPath);
|
|
@@ -322,120 +219,94 @@ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
|
322
219
|
const current = await readFile(absolutePath, "utf8");
|
|
323
220
|
const next = current.replace(params.oldText, params.newText);
|
|
324
221
|
await writeFile(absolutePath, next, "utf8");
|
|
325
|
-
|
|
326
222
|
return {
|
|
327
223
|
content: [{ type: "text", text: `Updated ${normalizedPath}` }],
|
|
328
|
-
details: {},
|
|
224
|
+
details: { path: normalizedPath },
|
|
329
225
|
};
|
|
330
226
|
});
|
|
331
227
|
}
|
|
332
228
|
```
|
|
333
229
|
|
|
334
|
-
|
|
230
|
+
## Concurrency
|
|
335
231
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
Use `onUpdate` to stream partial results while the tool executes. This gives the user feedback during long operations.
|
|
232
|
+
Pi runs sibling tool calls in parallel by default. Add `executionMode: "sequential"` when this tool must serialize with sibling tool calls, for example when it mutates shared in-memory state, drives a cursor/game, or depends on strict call order.
|
|
339
233
|
|
|
340
234
|
```typescript
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
details: { complete: true },
|
|
352
|
-
};
|
|
353
|
-
},
|
|
235
|
+
const statefulTool = defineTool({
|
|
236
|
+
name: "game_move",
|
|
237
|
+
label: "Game Move",
|
|
238
|
+
description: "Apply a move to the current game state.",
|
|
239
|
+
executionMode: "sequential",
|
|
240
|
+
parameters,
|
|
241
|
+
async execute(_toolCallId, params) {
|
|
242
|
+
// Mutates shared in-memory state safely.
|
|
243
|
+
},
|
|
244
|
+
});
|
|
354
245
|
```
|
|
355
246
|
|
|
356
|
-
|
|
247
|
+
Prefer making core operations concurrency-safe. Use sequential mode only when call order is inherently meaningful.
|
|
357
248
|
|
|
358
|
-
|
|
249
|
+
## Rendering
|
|
359
250
|
|
|
360
|
-
|
|
251
|
+
Custom renderers return TUI `Component` objects.
|
|
361
252
|
|
|
362
|
-
|
|
363
|
-
renderCall(args: MyToolParams, theme: Theme): Component
|
|
364
|
-
```
|
|
365
|
-
|
|
366
|
-
A 3rd `context` param is available from the framework but rarely needed in `renderCall`.
|
|
253
|
+
### `renderCall`
|
|
367
254
|
|
|
368
|
-
Use `ToolCallHeader`
|
|
255
|
+
Use `ToolCallHeader` for a stable, scannable call line.
|
|
369
256
|
|
|
370
257
|
```typescript
|
|
371
|
-
renderCall(args
|
|
258
|
+
renderCall(args, theme) {
|
|
372
259
|
return new ToolCallHeader(
|
|
373
260
|
{
|
|
374
|
-
toolName: "
|
|
375
|
-
action:
|
|
376
|
-
mainArg:
|
|
377
|
-
optionArgs: [
|
|
378
|
-
longArgs: [
|
|
379
|
-
showColon: true, // Whether to show colon after tool name
|
|
261
|
+
toolName: "Repo Tree",
|
|
262
|
+
action: args.action,
|
|
263
|
+
mainArg: `${args.owner}/${args.repo}`,
|
|
264
|
+
optionArgs: args.path ? [{ label: "path", value: args.path }] : [],
|
|
265
|
+
longArgs: args.instructions ? [{ label: "instructions", value: args.instructions }] : [],
|
|
380
266
|
},
|
|
381
267
|
theme,
|
|
382
268
|
);
|
|
383
269
|
}
|
|
384
270
|
```
|
|
385
271
|
|
|
386
|
-
|
|
272
|
+
Header extraction order:
|
|
387
273
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
```
|
|
274
|
+
1. Action for multi-action tools.
|
|
275
|
+
2. One main argument users scan for first.
|
|
276
|
+
3. Short option args.
|
|
277
|
+
4. Long args on follow-up lines.
|
|
278
|
+
|
|
279
|
+
### `renderResult`
|
|
395
280
|
|
|
396
|
-
|
|
397
|
-
- `result.details` is `{}` (empty object) when the tool threw an error
|
|
398
|
-
- A 4th `context` param is available (see Error Handling above) but not required
|
|
281
|
+
Handle partial state first, then errors, then normal output.
|
|
399
282
|
|
|
400
283
|
```typescript
|
|
401
|
-
renderResult(
|
|
402
|
-
result: AgentToolResult<MyToolDetails>,
|
|
403
|
-
options: ToolRenderResultOptions,
|
|
404
|
-
theme: Theme,
|
|
405
|
-
) {
|
|
406
|
-
// 1. Handle partial state first with stable message
|
|
284
|
+
renderResult(result, options, theme) {
|
|
407
285
|
if (options.isPartial) {
|
|
408
|
-
return new Text(theme.fg("muted", "
|
|
286
|
+
return new Text(theme.fg("muted", "Repo Tree: fetching..."), 0, 0);
|
|
409
287
|
}
|
|
410
288
|
|
|
411
289
|
const { details } = result;
|
|
412
|
-
|
|
413
|
-
// 2. Handle errors: details is {} when tool threw
|
|
414
|
-
if (!details?.results) {
|
|
290
|
+
if (!details?.entries) {
|
|
415
291
|
const textBlock = result.content.find((c) => c.type === "text");
|
|
416
|
-
const
|
|
417
|
-
return new Text(theme.fg("error",
|
|
292
|
+
const message = textBlock?.type === "text" ? textBlock.text : "Repo Tree failed";
|
|
293
|
+
return new Text(theme.fg("error", message), 0, 0);
|
|
418
294
|
}
|
|
419
295
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const fields: Array<{ label: string; value: string; showCollapsed?: boolean } | Text> = [
|
|
424
|
-
{ label: "Results", value: `${items.length} items`, showCollapsed: true },
|
|
296
|
+
const fields = [
|
|
297
|
+
{ label: "Location", value: `${details.owner}/${details.repo}`, showCollapsed: true },
|
|
298
|
+
{ label: "Entries", value: `${details.entries.length}`, showCollapsed: true },
|
|
425
299
|
];
|
|
426
300
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
footerItems.push({ label: "count", value: `${items.length}` });
|
|
301
|
+
const footerItems = [];
|
|
302
|
+
if (!options.expanded && details.entries.length > 5) {
|
|
303
|
+
footerItems.push({ value: keyHint("app.tools.expand", "to expand") });
|
|
431
304
|
}
|
|
432
305
|
|
|
433
306
|
return new ToolBody(
|
|
434
307
|
{
|
|
435
308
|
fields,
|
|
436
|
-
footer: footerItems.length > 0
|
|
437
|
-
? new ToolFooter(theme, { items: footerItems, separator: " | " })
|
|
438
|
-
: undefined,
|
|
309
|
+
footer: footerItems.length > 0 ? new ToolFooter(theme, { items: footerItems }) : undefined,
|
|
439
310
|
includeSpacerBeforeFooter: fields.length > 0,
|
|
440
311
|
},
|
|
441
312
|
options,
|
|
@@ -444,590 +315,115 @@ renderResult(
|
|
|
444
315
|
}
|
|
445
316
|
```
|
|
446
317
|
|
|
447
|
-
### `renderCall` Design Guide
|
|
448
|
-
|
|
449
|
-
The `process` extension is a good baseline. Its call renderer is deterministic and keeps headers readable.
|
|
450
|
-
|
|
451
|
-
Use this extraction order when building header parts:
|
|
452
|
-
|
|
453
|
-
1. **Action first**: always show action for multi-action tools (`start`, `list`, `kill`, ...).
|
|
454
|
-
2. **Pick one main arg**: choose the single value the user scans for first (name, id, or short command).
|
|
455
|
-
3. **Promote short fields to options**: compact values become option args (`end=true`, `limit=10`).
|
|
456
|
-
4. **Demote long fields to long args**: commands/prompts/instructions move to labeled follow-up lines.
|
|
457
|
-
5. **Keep it stable**: same inputs should produce same ordering and formatting.
|
|
458
|
-
|
|
459
|
-
Implementation pattern:
|
|
460
|
-
- Build `mainArg`, `optionArgs`, `longArgs` first, then pass to `ToolCallHeader`.
|
|
461
|
-
- Quote user-provided names (`"backend"`) when that improves visual parsing.
|
|
462
|
-
- Cap inline length (e.g. 60-80 chars), then spill to `longArgs`.
|
|
463
|
-
|
|
464
|
-
### `renderResult` Guidelines
|
|
465
|
-
|
|
466
|
-
- Handle `isPartial` first with a **stable, tool-scoped message** like `"MyTool: fetching..."`. Do NOT echo streaming content.
|
|
467
|
-
- Handle errors by checking for missing expected fields in `details` (framework sets `details: {}` on throw). Alternatively use the 4th `context` param with `context.isError`.
|
|
468
|
-
- Use `ToolBody` with `showCollapsed: true` fields for collapsed/expanded filtering.
|
|
469
|
-
- Use `ToolFooter` for stats/metadata. Omit when empty: `footer: items.length > 0 ? new ToolFooter(...) : undefined`.
|
|
470
|
-
- Use `includeSpacerBeforeFooter: fields.length > 0` to avoid blank line when body is empty.
|
|
471
|
-
- Remove redundant success footer items — don't show `status: ok` when success is obvious.
|
|
472
|
-
- Use `Container` + `addChild()` for multi-element results instead of string concatenation.
|
|
473
|
-
- Use `Markdown` component for rich markdown content:
|
|
474
|
-
```typescript
|
|
475
|
-
new Markdown(text, 0, 0, getMarkdownTheme(), { color: (t) => theme.fg("toolOutput", t) })
|
|
476
|
-
```
|
|
477
|
-
- Use `keyHint("app.tools.expand", "to expand")` for expand hints.
|
|
478
|
-
- Collapsed result should show **actionable preview** (last N lines, first N items with status), not just a status badge.
|
|
479
|
-
- Humanize error messages with names first: `"Could not get X for "name" (id)"`.
|
|
480
|
-
|
|
481
|
-
## Tool UI Rendering Guidelines
|
|
482
|
-
|
|
483
|
-
When customizing tool rendering, keep call/result UI predictable and scannable.
|
|
484
|
-
|
|
485
|
-
### `renderCall` format
|
|
486
|
-
|
|
487
|
-
Use this line model:
|
|
488
|
-
|
|
489
|
-
- First line: `[Tool Name]: [Action] [Main arg] [Option args]`
|
|
490
|
-
- Additional lines: long args only
|
|
491
|
-
|
|
492
318
|
Guidelines:
|
|
493
|
-
- Tool name should be a human display label (`label`), not a raw internal identifier (`name`).
|
|
494
|
-
- Show `action` only when it adds meaning (multi-action tools like process managers).
|
|
495
|
-
- Main arg should be the primary thing user cares about (query, session id, target id/name).
|
|
496
|
-
- Option args should be compact key-value pairs (`limit=10`, `cwd=/path`).
|
|
497
|
-
- Long text (prompt/task/question/context/instructions) goes to additional lines.
|
|
498
|
-
- Prefer wrapping to preserve full meaning over aggressive truncation.
|
|
499
|
-
- For tools without actions, omit colon suffix after tool name if that reads better in your UI system.
|
|
500
|
-
|
|
501
|
-
### `renderResult` layout
|
|
502
|
-
|
|
503
|
-
- Handle `isPartial` first. Return a short stable loading state with tool-scoped message.
|
|
504
|
-
- Keep the first non-loading line as a status summary (`Found N results`, `Updated 3 files`, `Failed: reason`).
|
|
505
|
-
- Use `expanded` to switch between compact and full output. Compact should show the top few items plus an omission hint.
|
|
506
|
-
- Keep body content focused on state + key output; avoid dumping raw JSON unless it is the actual output.
|
|
507
|
-
- If you render a footer (stats, backend, counts), use `includeSpacerBeforeFooter` to control blank line.
|
|
508
|
-
- Keep footer concise and stable across states.
|
|
509
|
-
- Return `undefined` when custom rendering adds no value; fallback rendering is better than noisy UI.
|
|
510
|
-
|
|
511
|
-
## Tool Call + UI Consistency Contract
|
|
512
|
-
|
|
513
|
-
Use this contract to keep tool UX consistent across extensions:
|
|
514
|
-
|
|
515
|
-
1. **Call line is for scanability**: `renderCall` first line follows `[Tool Name]: [Action] [Main arg] [Option args]`.
|
|
516
|
-
2. **Result detects errors** by checking for missing expected fields in `details` (framework sets `details: {}` on throw).
|
|
517
|
-
3. **Long text moves down**: prompts, instructions, and context go to follow-up lines, not the call header.
|
|
518
|
-
4. **Partial updates use a fixed tool-scoped string**, not echoed streaming content.
|
|
519
|
-
5. **Expanded controls density**: compact view shows summary + subset; expanded view shows full detail.
|
|
520
|
-
6. **No mode leaks in tool renderers**: `renderCall`/`renderResult` should not branch on mode. Mode-specific behavior belongs in command/tool logic (`references/modes.md`).
|
|
521
|
-
|
|
522
|
-
Related references:
|
|
523
|
-
- `references/modes.md` for `custom()` fallback behavior and RPC/Print handling.
|
|
524
|
-
- `references/components.md` for interactive component authoring.
|
|
525
|
-
- `references/messages.md` for persistent display via `sendMessage` + `registerMessageRenderer`.
|
|
526
|
-
|
|
527
|
-
## Naming Conventions
|
|
528
319
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
For internal/custom tools, no prefix is needed:
|
|
538
|
-
|
|
539
|
-
```
|
|
540
|
-
get_current_time
|
|
541
|
-
processes
|
|
542
|
-
```
|
|
543
|
-
|
|
544
|
-
Use snake_case for all tool names.
|
|
545
|
-
|
|
546
|
-
## Abort Signal
|
|
547
|
-
|
|
548
|
-
The `signal` parameter lets you cancel long-running operations when the user interrupts (e.g. pressing Escape). If the tool does not forward the signal, the underlying operation keeps running even after the user cancels, wasting resources and API credits.
|
|
549
|
-
|
|
550
|
-
```typescript
|
|
551
|
-
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
552
|
-
const response = await fetch(url, { signal });
|
|
553
|
-
// If the user cancels, fetch throws an AbortError
|
|
554
|
-
return { content: [{ type: "text", text: await response.text() }], details: {} };
|
|
555
|
-
},
|
|
556
|
-
```
|
|
557
|
-
|
|
558
|
-
Pass `signal` to every async operation that supports it: `fetch()` calls, `pi.exec()` calls, SDK clients, etc.
|
|
559
|
-
|
|
560
|
-
When wrapping an API client, thread the signal through the entire call chain. The client methods should accept an optional `signal` and forward it to the underlying `fetch()`:
|
|
561
|
-
|
|
562
|
-
```typescript
|
|
563
|
-
// In the tool:
|
|
564
|
-
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
565
|
-
const result = await client.search({ query: params.query, signal });
|
|
566
|
-
// ...
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// In the client:
|
|
570
|
-
async search(params: { query: string; signal?: AbortSignal }) {
|
|
571
|
-
return this.request("/search", { method: "POST", body: ... }, params.signal);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
private async request<T>(endpoint: string, options: RequestInit = {}, signal?: AbortSignal) {
|
|
575
|
-
return fetch(`${BASE_URL}${endpoint}`, { ...options, signal, headers: { ... } });
|
|
576
|
-
}
|
|
577
|
-
```
|
|
578
|
-
|
|
579
|
-
Do not prefix signal with underscore (`_signal`) unless the tool genuinely cannot use it. A dangling `_signal` is a sign of a missing cancellation path.
|
|
320
|
+
- Partial output uses a fixed tool-scoped message; do not echo streaming content.
|
|
321
|
+
- Collapsed view should show useful preview, not just `ok`.
|
|
322
|
+
- Expanded view can add full details.
|
|
323
|
+
- Omit empty footers.
|
|
324
|
+
- Use `Markdown` with `getMarkdownTheme()` for rich markdown.
|
|
325
|
+
- Use `context.lastComponent` only when reusing a component instance buys real performance.
|
|
326
|
+
- Set `renderShell: "self"` only when the default boxed shell prevents the desired layout.
|
|
580
327
|
|
|
581
328
|
## Output Truncation
|
|
582
329
|
|
|
583
|
-
|
|
330
|
+
Tools must avoid overwhelming model context. Use `truncateHead` for content where the beginning matters and `truncateTail` for logs or command output where the end matters.
|
|
584
331
|
|
|
585
332
|
```typescript
|
|
586
|
-
import { truncateHead, formatSize } from "@mariozechner/pi-coding-agent";
|
|
587
|
-
import { createWriteStream } from "fs";
|
|
588
|
-
import { tmpdir } from "os";
|
|
589
|
-
import { join } from "path";
|
|
590
|
-
|
|
591
333
|
interface FetchDetails {
|
|
592
|
-
content: string;
|
|
593
334
|
url: string;
|
|
335
|
+
content: string;
|
|
594
336
|
truncated: boolean;
|
|
595
337
|
totalLines: number;
|
|
596
|
-
totalBytes: number;
|
|
597
338
|
outputLines: number;
|
|
339
|
+
totalBytes: number;
|
|
598
340
|
outputBytes: number;
|
|
599
341
|
tempFile?: string;
|
|
600
342
|
}
|
|
601
343
|
|
|
602
|
-
async execute(
|
|
603
|
-
_toolCallId: string,
|
|
604
|
-
params: FetchParams,
|
|
605
|
-
signal: AbortSignal | undefined,
|
|
606
|
-
_onUpdate: undefined,
|
|
607
|
-
_ctx: ExtensionContext,
|
|
608
|
-
): Promise<AgentToolResult<FetchDetails>> {
|
|
344
|
+
async execute(_toolCallId, params, signal): Promise<AgentToolResult<FetchDetails>> {
|
|
609
345
|
const response = await fetch(params.url, { signal });
|
|
610
346
|
const fullContent = await response.text();
|
|
611
|
-
|
|
612
|
-
// truncateHead keeps the tail (most recent content)
|
|
613
|
-
const truncated = truncateHead(fullContent, {
|
|
614
|
-
maxBytes: 50000,
|
|
615
|
-
maxLines: 2000,
|
|
616
|
-
});
|
|
347
|
+
const truncated = truncateHead(fullContent, { maxBytes: 50_000, maxLines: 2_000 });
|
|
617
348
|
|
|
618
349
|
const details: FetchDetails = {
|
|
619
|
-
content: truncated.content,
|
|
620
350
|
url: params.url,
|
|
351
|
+
content: truncated.content,
|
|
621
352
|
truncated: truncated.truncated,
|
|
622
353
|
totalLines: truncated.totalLines,
|
|
623
|
-
totalBytes: truncated.totalBytes,
|
|
624
354
|
outputLines: truncated.outputLines,
|
|
355
|
+
totalBytes: truncated.totalBytes,
|
|
625
356
|
outputBytes: truncated.outputBytes,
|
|
626
357
|
};
|
|
627
358
|
|
|
628
|
-
|
|
359
|
+
let text = truncated.content;
|
|
629
360
|
if (truncated.truncated) {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
details.tempFile = tempFile;
|
|
361
|
+
details.tempFile = await writeTempFile(fullContent);
|
|
362
|
+
text += `\n\n[Output truncated: ${truncated.outputLines}/${truncated.totalLines} lines, `;
|
|
363
|
+
text += `${formatSize(truncated.outputBytes)}/${formatSize(truncated.totalBytes)}. `;
|
|
364
|
+
text += `Full output: ${details.tempFile}]`;
|
|
635
365
|
}
|
|
636
366
|
|
|
637
|
-
return {
|
|
638
|
-
content: [{ type: "text", text: truncated.content }],
|
|
639
|
-
details,
|
|
640
|
-
};
|
|
367
|
+
return { content: [{ type: "text", text }], details };
|
|
641
368
|
}
|
|
642
369
|
```
|
|
643
370
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
```typescript
|
|
647
|
-
interface TruncationResult {
|
|
648
|
-
content: string; // The truncated content (or full if not truncated)
|
|
649
|
-
truncated: boolean; // Whether truncation occurred
|
|
650
|
-
totalLines: number; // Original total lines
|
|
651
|
-
totalBytes: number; // Original total bytes
|
|
652
|
-
outputLines: number; // Lines in output
|
|
653
|
-
outputBytes: number; // Bytes in output
|
|
654
|
-
}
|
|
655
|
-
```
|
|
656
|
-
|
|
657
|
-
### Rendering Truncated Output
|
|
658
|
-
|
|
659
|
-
```typescript
|
|
660
|
-
renderResult(
|
|
661
|
-
result: AgentToolResult<FetchDetails>,
|
|
662
|
-
options: ToolRenderResultOptions,
|
|
663
|
-
theme: Theme,
|
|
664
|
-
) {
|
|
665
|
-
if (options.isPartial) {
|
|
666
|
-
return new Text(theme.fg("muted", "WebFetch: fetching..."), 0, 0);
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
const { details } = result;
|
|
670
|
-
|
|
671
|
-
if (!details?.content) {
|
|
672
|
-
const textBlock = result.content.find((c) => c.type === "text");
|
|
673
|
-
const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Fetch failed";
|
|
674
|
-
return new Text(theme.fg("error", errorMsg), 0, 0);
|
|
675
|
-
}
|
|
676
|
-
const container = new Container();
|
|
677
|
-
|
|
678
|
-
// Body with content preview
|
|
679
|
-
const fields = [
|
|
680
|
-
{ label: "URL", value: details.url, showCollapsed: true },
|
|
681
|
-
{ label: "Size", value: formatSize(details.totalBytes), showCollapsed: true },
|
|
682
|
-
];
|
|
683
|
-
|
|
684
|
-
container.addChild(new ToolBody(theme, { fields, expanded: options.expanded }));
|
|
685
|
-
|
|
686
|
-
// Footer with truncation info
|
|
687
|
-
const footerItems = [];
|
|
688
|
-
|
|
689
|
-
if (details.truncated) {
|
|
690
|
-
footerItems.push(
|
|
691
|
-
{ label: "showing", value: `${details.outputLines}/${details.totalLines} lines` },
|
|
692
|
-
{ label: "full output", value: details.tempFile ?? "temp file" },
|
|
693
|
-
);
|
|
694
|
-
} else {
|
|
695
|
-
footerItems.push({ label: "lines", value: `${details.totalLines}` });
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
container.addChild(
|
|
699
|
-
new ToolFooter(theme, {
|
|
700
|
-
items: footerItems,
|
|
701
|
-
separator: " | ",
|
|
702
|
-
includeSpacerBeforeFooter: fields.length > 0,
|
|
703
|
-
}),
|
|
704
|
-
);
|
|
705
|
-
|
|
706
|
-
return container;
|
|
707
|
-
}
|
|
708
|
-
```
|
|
371
|
+
Always tell the LLM when output was truncated and where full output was saved.
|
|
709
372
|
|
|
710
373
|
## Multi-Action Tools
|
|
711
374
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
### File Structure
|
|
375
|
+
Use a directory when one tool has several actions.
|
|
715
376
|
|
|
716
377
|
```
|
|
717
|
-
tools/
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
render.ts // renderCall and renderResult (when complex)
|
|
725
|
-
types.ts // Shared types and param schema
|
|
378
|
+
tools/my_tool/
|
|
379
|
+
index.ts
|
|
380
|
+
actions/start.ts
|
|
381
|
+
actions/list.ts
|
|
382
|
+
actions/kill.ts
|
|
383
|
+
render.ts
|
|
384
|
+
types.ts
|
|
726
385
|
```
|
|
727
386
|
|
|
728
|
-
|
|
387
|
+
Pattern:
|
|
729
388
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
export interface StartParams {
|
|
735
|
-
name: string;
|
|
736
|
-
command: string;
|
|
737
|
-
cwd?: string;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
export interface StartResult {
|
|
741
|
-
sessionId: string;
|
|
742
|
-
pid: number;
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
export async function start(
|
|
746
|
-
client: MyClient,
|
|
747
|
-
params: StartParams,
|
|
748
|
-
signal?: AbortSignal,
|
|
749
|
-
): Promise<StartResult> {
|
|
750
|
-
return client.startSession({
|
|
751
|
-
name: params.name,
|
|
752
|
-
command: params.command,
|
|
753
|
-
cwd: params.cwd,
|
|
754
|
-
signal,
|
|
755
|
-
});
|
|
756
|
-
}
|
|
757
|
-
```
|
|
389
|
+
- `types.ts`: parameter schema, `Static` alias, details/result types.
|
|
390
|
+
- `actions/*`: Pi-free or mostly Pi-free action logic.
|
|
391
|
+
- `render.ts`: `renderCall`/`renderResult` when complex.
|
|
392
|
+
- `index.ts`: `defineTool`, execute switch, registration.
|
|
758
393
|
|
|
759
|
-
|
|
394
|
+
Inside the `defineTool` execute callback, inference is enough. If action functions need typed inputs, use the alias from `types.ts`.
|
|
760
395
|
|
|
761
396
|
```typescript
|
|
762
|
-
|
|
763
|
-
async execute(
|
|
764
|
-
toolCallId: string,
|
|
765
|
-
params: MyToolParams,
|
|
766
|
-
signal: AbortSignal | undefined,
|
|
767
|
-
onUpdate: AgentToolUpdateCallback<MyToolDetails> | undefined,
|
|
768
|
-
ctx: ExtensionContext,
|
|
769
|
-
): Promise<AgentToolResult<MyToolDetails>> {
|
|
397
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx): Promise<AgentToolResult<MyToolDetails>> {
|
|
770
398
|
switch (params.action) {
|
|
771
399
|
case "start":
|
|
772
400
|
return startAction(client, params, signal, onUpdate, ctx);
|
|
773
401
|
case "list":
|
|
774
402
|
return listAction(client, params, signal);
|
|
775
|
-
case "kill":
|
|
776
|
-
return killAction(client, params, signal);
|
|
777
403
|
default:
|
|
778
404
|
throw new Error(`Unknown action: ${params.action}`);
|
|
779
405
|
}
|
|
780
406
|
}
|
|
781
407
|
```
|
|
782
408
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
When rendering is complex, extract to `render.ts`:
|
|
786
|
-
|
|
787
|
-
```typescript
|
|
788
|
-
// render.ts
|
|
789
|
-
export function renderCall(args: MyToolParams, theme: Theme) {
|
|
790
|
-
return new ToolCallHeader(
|
|
791
|
-
{
|
|
792
|
-
toolName: "MyTool",
|
|
793
|
-
action: args.action,
|
|
794
|
-
mainArg: getMainArg(args),
|
|
795
|
-
optionArgs: getOptionArgs(args),
|
|
796
|
-
longArgs: getLongArgs(args),
|
|
797
|
-
},
|
|
798
|
-
theme,
|
|
799
|
-
);
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
export function renderResult(
|
|
803
|
-
result: AgentToolResult<MyToolDetails>,
|
|
804
|
-
options: ToolRenderResultOptions,
|
|
805
|
-
theme: Theme,
|
|
806
|
-
) {
|
|
807
|
-
// Complex rendering logic here
|
|
808
|
-
}
|
|
809
|
-
```
|
|
810
|
-
|
|
811
|
-
```typescript
|
|
812
|
-
// index.ts
|
|
813
|
-
import { renderCall, renderResult } from "./render.js";
|
|
814
|
-
|
|
815
|
-
const myTool = {
|
|
816
|
-
// ...
|
|
817
|
-
renderCall,
|
|
818
|
-
renderResult,
|
|
819
|
-
};
|
|
820
|
-
```
|
|
409
|
+
## Naming
|
|
821
410
|
|
|
822
|
-
|
|
823
|
-
-
|
|
824
|
-
-
|
|
411
|
+
- Use snake_case tool names.
|
|
412
|
+
- Prefix third-party integrations with the API/product name (`linkup_web_search`, `linear_issue`).
|
|
413
|
+
- Internal tools can use direct names (`get_current_time`, `process`).
|
|
414
|
+
- Display names in UI should use `label`, not raw tool IDs.
|
|
825
415
|
|
|
826
|
-
##
|
|
416
|
+
## Checklist
|
|
827
417
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
import { Container, Text } from "@mariozechner/pi-tui";
|
|
841
|
-
import { type Static, Type } from "@sinclair/typebox";
|
|
842
|
-
|
|
843
|
-
// Schema
|
|
844
|
-
const parameters = Type.Object({
|
|
845
|
-
owner: Type.String({ description: "Repository owner" }),
|
|
846
|
-
repo: Type.String({ description: "Repository name" }),
|
|
847
|
-
path: Type.Optional(Type.String({ description: "File or directory path", default: "" })),
|
|
848
|
-
});
|
|
849
|
-
|
|
850
|
-
// Typed aliases
|
|
851
|
-
type RepoTreeParams = Static<typeof parameters>;
|
|
852
|
-
|
|
853
|
-
interface RepoTreeDetails {
|
|
854
|
-
owner: string;
|
|
855
|
-
repo: string;
|
|
856
|
-
path: string;
|
|
857
|
-
entries: Array<{ name: string; type: "file" | "dir"; size?: number }>;
|
|
858
|
-
truncated: boolean;
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
// Tool definition
|
|
862
|
-
const repoTreeTool = {
|
|
863
|
-
name: "repo_tree",
|
|
864
|
-
label: "Repo Tree",
|
|
865
|
-
description: "List files and directories in a GitHub repository.",
|
|
866
|
-
promptSnippet: "Browse repository file structure",
|
|
867
|
-
promptGuidelines: [
|
|
868
|
-
"Use repo_tree to explore repository structure before reading files.",
|
|
869
|
-
"Start repo_tree at the root path, then drill down into directories.",
|
|
870
|
-
],
|
|
871
|
-
parameters,
|
|
872
|
-
|
|
873
|
-
async execute(
|
|
874
|
-
_toolCallId: string,
|
|
875
|
-
params: RepoTreeParams,
|
|
876
|
-
signal: AbortSignal | undefined,
|
|
877
|
-
_onUpdate: undefined,
|
|
878
|
-
_ctx: ExtensionContext,
|
|
879
|
-
): Promise<AgentToolResult<RepoTreeDetails>> {
|
|
880
|
-
const response = await fetch(
|
|
881
|
-
`https://api.github.com/repos/${params.owner}/${params.repo}/contents/${params.path ?? ""}`,
|
|
882
|
-
{ signal, headers: { Accept: "application/vnd.github.v3+json" } },
|
|
883
|
-
);
|
|
884
|
-
|
|
885
|
-
if (!response.ok) {
|
|
886
|
-
if (response.status === 404) {
|
|
887
|
-
throw new Error(
|
|
888
|
-
`Could not find repository "${params.owner}/${params.repo}" or path "${params.path ?? ""}"`,
|
|
889
|
-
);
|
|
890
|
-
}
|
|
891
|
-
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
const data = await response.json();
|
|
895
|
-
|
|
896
|
-
// GitHub returns a single object for files, array for directories
|
|
897
|
-
const entries = Array.isArray(data) ? data : [data];
|
|
898
|
-
|
|
899
|
-
const processed = entries.map((entry: any) => ({
|
|
900
|
-
name: entry.name,
|
|
901
|
-
type: entry.type === "dir" ? "dir" as const : "file" as const,
|
|
902
|
-
size: entry.size,
|
|
903
|
-
}));
|
|
904
|
-
|
|
905
|
-
// Sort: directories first, then files, alphabetically within each
|
|
906
|
-
processed.sort((a, b) => {
|
|
907
|
-
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
|
908
|
-
return a.name.localeCompare(b.name);
|
|
909
|
-
});
|
|
910
|
-
|
|
911
|
-
const truncated = processed.length > 100;
|
|
912
|
-
|
|
913
|
-
return {
|
|
914
|
-
content: [
|
|
915
|
-
{
|
|
916
|
-
type: "text",
|
|
917
|
-
text: JSON.stringify(truncated ? processed.slice(0, 100) : processed, null, 2),
|
|
918
|
-
},
|
|
919
|
-
],
|
|
920
|
-
details: {
|
|
921
|
-
owner: params.owner,
|
|
922
|
-
repo: params.repo,
|
|
923
|
-
path: params.path ?? "",
|
|
924
|
-
entries: processed,
|
|
925
|
-
truncated,
|
|
926
|
-
},
|
|
927
|
-
};
|
|
928
|
-
},
|
|
929
|
-
|
|
930
|
-
renderCall(args: RepoTreeParams, theme: Theme) {
|
|
931
|
-
return new ToolCallHeader(
|
|
932
|
-
{
|
|
933
|
-
toolName: "Repo Tree",
|
|
934
|
-
mainArg: `${args.owner}/${args.repo}`,
|
|
935
|
-
optionArgs: args.path ? [`path=${args.path}`] : [],
|
|
936
|
-
longArgs: [],
|
|
937
|
-
},
|
|
938
|
-
theme,
|
|
939
|
-
);
|
|
940
|
-
},
|
|
941
|
-
|
|
942
|
-
renderResult(
|
|
943
|
-
result: AgentToolResult<RepoTreeDetails>,
|
|
944
|
-
options: ToolRenderResultOptions,
|
|
945
|
-
theme: Theme,
|
|
946
|
-
) {
|
|
947
|
-
// 1. Stable partial message
|
|
948
|
-
if (options.isPartial) {
|
|
949
|
-
return new Text(theme.fg("muted", "RepoTree: fetching..."), 0, 0);
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
const { details } = result;
|
|
953
|
-
|
|
954
|
-
// 2. Error handling: details is {} when tool threw
|
|
955
|
-
if (!details?.entries) {
|
|
956
|
-
const textBlock = result.content.find((c) => c.type === "text");
|
|
957
|
-
const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Failed to list repository";
|
|
958
|
-
return new Text(theme.fg("error", errorMsg), 0, 0);
|
|
959
|
-
}
|
|
960
|
-
const container = new Container();
|
|
961
|
-
const entries = details?.entries ?? [];
|
|
962
|
-
const dirs = entries.filter((e) => e.type === "dir");
|
|
963
|
-
const files = entries.filter((e) => e.type === "file");
|
|
964
|
-
|
|
965
|
-
// 3. Body fields with showCollapsed for collapsed/expanded control
|
|
966
|
-
const fields: Array<{ label: string; value: string; showCollapsed: boolean }> = [
|
|
967
|
-
{
|
|
968
|
-
label: "Location",
|
|
969
|
-
value: `${details.owner}/${details.repo}${details.path ? `/${details.path}` : ""}`,
|
|
970
|
-
showCollapsed: true,
|
|
971
|
-
},
|
|
972
|
-
{
|
|
973
|
-
label: "Contents",
|
|
974
|
-
value: `${dirs.length} dirs, ${files.length} files`,
|
|
975
|
-
showCollapsed: true,
|
|
976
|
-
},
|
|
977
|
-
];
|
|
978
|
-
|
|
979
|
-
container.addChild(new ToolBody(theme, { fields, expanded: options.expanded }));
|
|
980
|
-
|
|
981
|
-
// 4. Entry list (in expanded view)
|
|
982
|
-
if (options.expanded && entries.length > 0) {
|
|
983
|
-
const entryLines = entries
|
|
984
|
-
.slice(0, 50)
|
|
985
|
-
.map((e) => {
|
|
986
|
-
const icon = e.type === "dir" ? "📁" : "📄";
|
|
987
|
-
const size = e.size !== undefined ? ` (${formatSize(e.size)})` : "";
|
|
988
|
-
return `${icon} ${e.name}${size}`;
|
|
989
|
-
})
|
|
990
|
-
.join("\n");
|
|
991
|
-
|
|
992
|
-
container.addChild(new Text(entryLines, 0, 0));
|
|
993
|
-
|
|
994
|
-
if (entries.length > 50) {
|
|
995
|
-
container.addChild(
|
|
996
|
-
new Text(theme.fg("muted", `... and ${entries.length - 50} more`), 0, 0),
|
|
997
|
-
);
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
// 5. Conditional footer
|
|
1002
|
-
const footerItems = [];
|
|
1003
|
-
|
|
1004
|
-
if (details?.truncated) {
|
|
1005
|
-
footerItems.push({ label: "showing", value: "first 100 entries" });
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
if (entries.length > 0) {
|
|
1009
|
-
footerItems.push({ label: "total", value: `${entries.length} items` });
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
if (!options.expanded && entries.length > 5) {
|
|
1013
|
-
footerItems.push({ label: "", value: keyHint("app.tools.expand", "to expand") });
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
if (footerItems.length > 0) {
|
|
1017
|
-
container.addChild(
|
|
1018
|
-
new ToolFooter(theme, {
|
|
1019
|
-
items: footerItems,
|
|
1020
|
-
separator: " | ",
|
|
1021
|
-
includeSpacerBeforeFooter: fields.length > 0,
|
|
1022
|
-
}),
|
|
1023
|
-
);
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
return container;
|
|
1027
|
-
},
|
|
1028
|
-
};
|
|
1029
|
-
|
|
1030
|
-
export default function (pi: ExtensionAPI) {
|
|
1031
|
-
pi.registerTool(repoTreeTool);
|
|
1032
|
-
}
|
|
1033
|
-
```
|
|
418
|
+
- [ ] Tool uses `defineTool({...})` with no explicit generic args.
|
|
419
|
+
- [ ] Params infer from `parameters`; helper APIs use `Static<typeof parameters>` alias.
|
|
420
|
+
- [ ] `label`, `description`, and useful parameter descriptions are present.
|
|
421
|
+
- [ ] `promptGuidelines` bullets name the exact tool.
|
|
422
|
+
- [ ] Execute signature is `(toolCallId, params, signal, onUpdate, ctx)`.
|
|
423
|
+
- [ ] `onUpdate` uses optional chaining.
|
|
424
|
+
- [ ] `signal` is forwarded to async operations.
|
|
425
|
+
- [ ] Errors are thrown, not returned as fake success payloads.
|
|
426
|
+
- [ ] Path tools strip leading `@`; mutating tools use `withFileMutationQueue()`.
|
|
427
|
+
- [ ] Shared-state tools use safe concurrency or `executionMode: "sequential"`.
|
|
428
|
+
- [ ] Large outputs are truncated with a full-output temp file.
|
|
429
|
+
- [ ] Renderers return components, handle partial first, handle errors, and omit empty footers.
|