fix(coding-agent): built-in tools work like extension tools
Export readToolDefinition / createReadToolDefinition and the equivalent built-in ToolDefinition APIs from @mariozechner/pi-coding-agent.
This commit is contained in:
@@ -2,6 +2,15 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Changed `ToolDefinition.renderCall` and `renderResult` semantics. Fallback rendering now happens only when a renderer is not defined for that slot. If `renderCall` or `renderResult` is defined, it must return a `Component`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Built-in tools now work like custom tools in extensions. To get built-in tool definitions, import `readToolDefinition` / `createReadToolDefinition()` and the equivalent `bash`, `edit`, `write`, `grep`, `find`, and `ls` exports from `@mariozechner/pi-coding-agent`.
|
||||
- Cleaned up `buildSystemPrompt()` so built-in tool snippets and tool-local guidelines come from built-in `ToolDefinition` metadata, while cross-tool and global prompt rules stay in system prompt construction.
|
||||
|
||||
## [0.61.1] - 2026-03-20
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -976,8 +976,8 @@ pi.registerTool({
|
||||
},
|
||||
|
||||
// Optional: Custom rendering
|
||||
renderCall(args, theme) { ... },
|
||||
renderResult(result, options, theme) { ... },
|
||||
renderCall(args, theme, context) { ... },
|
||||
renderResult(result, options, theme, context) { ... },
|
||||
});
|
||||
```
|
||||
|
||||
@@ -1427,8 +1427,8 @@ pi.registerTool({
|
||||
},
|
||||
|
||||
// Optional: Custom rendering
|
||||
renderCall(args, theme) { ... },
|
||||
renderResult(result, options, theme) { ... },
|
||||
renderCall(args, theme, context) { ... },
|
||||
renderResult(result, options, theme, context) { ... },
|
||||
});
|
||||
```
|
||||
|
||||
@@ -1463,7 +1463,9 @@ pi --no-tools -e ./my-extension.ts
|
||||
|
||||
See [examples/extensions/tool-override.ts](../examples/extensions/tool-override.ts) for a complete example that overrides `read` with logging and access control.
|
||||
|
||||
**Rendering:** If your override doesn't provide custom `renderCall`/`renderResult` functions, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI.
|
||||
**Rendering:** Built-in renderer inheritance is resolved per slot. Execution override and rendering override are independent. If your override omits `renderCall`, the built-in `renderCall` is used. If your override omits `renderResult`, the built-in `renderResult` is used. If your override omits both, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI.
|
||||
|
||||
**Prompt metadata:** `promptSnippet` and `promptGuidelines` are not inherited from the built-in tool. If your override should keep those prompt instructions, define them on the override explicitly.
|
||||
|
||||
**Your implementation must match the exact result shape**, including the `details` type. The UI and session logic depend on these shapes for rendering and state tracking.
|
||||
|
||||
@@ -1597,44 +1599,52 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
### Custom Rendering
|
||||
|
||||
Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API and [tool-execution.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/modes/interactive/components/tool-execution.ts) for how built-in tools render.
|
||||
Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API and [tool-execution.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/modes/interactive/components/tool-execution.ts) for how tool rows are composed.
|
||||
|
||||
Tool output is wrapped in a `Box` that handles padding and background. Your render methods return `Component` instances (typically `Text`).
|
||||
Tool output is wrapped in a `Box` that handles padding and background. A defined `renderCall` or `renderResult` must return a `Component`. If a slot renderer is not defined, `tool-execution.ts` uses fallback rendering for that slot.
|
||||
|
||||
`renderCall` and `renderResult` each receive a `context` object with:
|
||||
- `args` - the current tool call arguments
|
||||
- `state` - shared row-local state across `renderCall` and `renderResult`
|
||||
- `lastComponent` - the previously returned component for that slot, if any
|
||||
- `invalidate()` - request a rerender of this tool row
|
||||
- `toolCallId`, `cwd`, `executionStarted`, `argsComplete`, `isPartial`, `expanded`, `showImages`, `isError`
|
||||
|
||||
Use `context.state` for cross-slot shared state. Keep slot-local caches on the returned component instance when you want to reuse and mutate the same component across renders.
|
||||
|
||||
#### renderCall
|
||||
|
||||
Renders the tool call (before/during execution):
|
||||
Renders the tool call or header:
|
||||
|
||||
```typescript
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
|
||||
renderCall(args, theme) {
|
||||
let text = theme.fg("toolTitle", theme.bold("my_tool "));
|
||||
text += theme.fg("muted", args.action);
|
||||
renderCall(args, theme, context) {
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
let content = theme.fg("toolTitle", theme.bold("my_tool "));
|
||||
content += theme.fg("muted", args.action);
|
||||
if (args.text) {
|
||||
text += " " + theme.fg("dim", `"${args.text}"`);
|
||||
content += " " + theme.fg("dim", `"${args.text}"`);
|
||||
}
|
||||
return new Text(text, 0, 0); // 0,0 padding - Box handles it
|
||||
text.setText(content);
|
||||
return text;
|
||||
}
|
||||
```
|
||||
|
||||
#### renderResult
|
||||
|
||||
Renders the tool result:
|
||||
Renders the tool result or output:
|
||||
|
||||
```typescript
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
// Handle streaming
|
||||
renderResult(result, { expanded, isPartial }, theme, context) {
|
||||
if (isPartial) {
|
||||
return new Text(theme.fg("warning", "Processing..."), 0, 0);
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (result.details?.error) {
|
||||
return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0);
|
||||
}
|
||||
|
||||
// Normal result - support expanded view (Ctrl+O)
|
||||
let text = theme.fg("success", "✓ Done");
|
||||
if (expanded && result.details?.items) {
|
||||
for (const item of result.details.items) {
|
||||
@@ -1645,6 +1655,8 @@ renderResult(result, { expanded, isPartial }, theme) {
|
||||
}
|
||||
```
|
||||
|
||||
If a slot intentionally has no visible content, return an empty `Component` such as an empty `Container`.
|
||||
|
||||
#### Keybinding Hints
|
||||
|
||||
Use `keyHint()` to display keybinding hints that respect the active keybinding configuration:
|
||||
@@ -1652,7 +1664,7 @@ Use `keyHint()` to display keybinding hints that respect the active keybinding c
|
||||
```typescript
|
||||
import { keyHint } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
renderResult(result, { expanded }, theme, context) {
|
||||
let text = theme.fg("success", "✓ Done");
|
||||
if (!expanded) {
|
||||
text += ` (${keyHint("app.tools.expand", "to expand")})`;
|
||||
@@ -1676,16 +1688,19 @@ Custom editors and `ctx.ui.custom()` components receive `keybindings: Keybinding
|
||||
|
||||
#### Best Practices
|
||||
|
||||
- Use `Text` with padding `(0, 0)` - the Box handles padding
|
||||
- Use `\n` for multi-line content
|
||||
- Handle `isPartial` for streaming progress
|
||||
- Support `expanded` for detail on demand
|
||||
- Keep default view compact
|
||||
- Use `Text` with padding `(0, 0)`. The Box handles padding.
|
||||
- Use `\n` for multi-line content.
|
||||
- Handle `isPartial` for streaming progress.
|
||||
- Support `expanded` for detail on demand.
|
||||
- Keep default view compact.
|
||||
- Read `context.args` in `renderResult` instead of copying args into `context.state`.
|
||||
- Use `context.state` only for data that must be shared across call and result slots.
|
||||
- Reuse `context.lastComponent` when the same component instance can be updated in place.
|
||||
|
||||
#### Fallback
|
||||
|
||||
If `renderCall`/`renderResult` is not defined or throws:
|
||||
- `renderCall`: Shows tool name
|
||||
If a slot renderer is not defined or throws:
|
||||
- `renderCall`: Shows the tool name
|
||||
- `renderResult`: Shows raw text from `content`
|
||||
|
||||
## Custom UI
|
||||
|
||||
@@ -394,7 +394,7 @@ Components accept theme objects for styling.
|
||||
**In `renderCall`/`renderResult`**, use the `theme` parameter:
|
||||
|
||||
```typescript
|
||||
renderResult(result, options, theme) {
|
||||
renderResult(result, options, theme, context) {
|
||||
// Use theme.fg() for foreground colors
|
||||
return new Text(theme.fg("success", "Done!"), 0, 0);
|
||||
|
||||
@@ -428,7 +428,7 @@ renderResult(result, options, theme) {
|
||||
import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
|
||||
import { Markdown } from "@mariozechner/pi-tui";
|
||||
|
||||
renderResult(result, options, theme) {
|
||||
renderResult(result, options, theme, context) {
|
||||
const mdTheme = getMarkdownTheme();
|
||||
return new Markdown(result.details.markdown, 0, 0, mdTheme);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return originalRead.execute(toolCallId, params, signal, onUpdate);
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
let text = theme.fg("toolTitle", theme.bold("read "));
|
||||
text += theme.fg("accent", args.path);
|
||||
if (args.offset || args.limit) {
|
||||
@@ -54,7 +54,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
renderResult(result, { expanded, isPartial }, theme, _context) {
|
||||
if (isPartial) return new Text(theme.fg("warning", "Reading..."), 0, 0);
|
||||
|
||||
const details = result.details as ReadToolDetails | undefined;
|
||||
@@ -101,7 +101,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return originalBash.execute(toolCallId, params, signal, onUpdate);
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
let text = theme.fg("toolTitle", theme.bold("$ "));
|
||||
const cmd = args.command.length > 80 ? `${args.command.slice(0, 77)}...` : args.command;
|
||||
text += theme.fg("accent", cmd);
|
||||
@@ -111,7 +111,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
renderResult(result, { expanded, isPartial }, theme, _context) {
|
||||
if (isPartial) return new Text(theme.fg("warning", "Running..."), 0, 0);
|
||||
|
||||
const details = result.details as BashToolDetails | undefined;
|
||||
@@ -160,13 +160,13 @@ export default function (pi: ExtensionAPI) {
|
||||
return originalEdit.execute(toolCallId, params, signal, onUpdate);
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
let text = theme.fg("toolTitle", theme.bold("edit "));
|
||||
text += theme.fg("accent", args.path);
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
renderResult(result, { expanded, isPartial }, theme, _context) {
|
||||
if (isPartial) return new Text(theme.fg("warning", "Editing..."), 0, 0);
|
||||
|
||||
const details = result.details as EditToolDetails | undefined;
|
||||
@@ -224,7 +224,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return originalWrite.execute(toolCallId, params, signal, onUpdate);
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
let text = theme.fg("toolTitle", theme.bold("write "));
|
||||
text += theme.fg("accent", args.path);
|
||||
const lineCount = args.content.split("\n").length;
|
||||
@@ -232,7 +232,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { isPartial }, theme) {
|
||||
renderResult(result, { isPartial }, theme, _context) {
|
||||
if (isPartial) return new Text(theme.fg("warning", "Writing..."), 0, 0);
|
||||
|
||||
const content = result.content[0];
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return tools.read.execute(toolCallId, params, signal, onUpdate);
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
const path = shortenPath(args.path || "");
|
||||
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return new Text(`${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
renderResult(result, { expanded }, theme, _context) {
|
||||
// Minimal mode: show nothing in collapsed state
|
||||
if (!expanded) {
|
||||
return new Text("", 0, 0);
|
||||
@@ -127,7 +127,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return tools.bash.execute(toolCallId, params, signal, onUpdate);
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
const command = args.command || "...";
|
||||
const timeout = args.timeout as number | undefined;
|
||||
const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
|
||||
@@ -135,7 +135,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return new Text(theme.fg("toolTitle", theme.bold(`$ ${command}`)) + timeoutSuffix, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
renderResult(result, { expanded }, theme, _context) {
|
||||
// Minimal mode: show nothing in collapsed state
|
||||
if (!expanded) {
|
||||
return new Text("", 0, 0);
|
||||
@@ -176,7 +176,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return tools.write.execute(toolCallId, params, signal, onUpdate);
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
const path = shortenPath(args.path || "");
|
||||
const pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
|
||||
const lineCount = args.content ? args.content.split("\n").length : 0;
|
||||
@@ -185,7 +185,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return new Text(`${theme.fg("toolTitle", theme.bold("write"))} ${pathDisplay}${lineInfo}`, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
renderResult(result, { expanded }, theme, _context) {
|
||||
// Minimal mode: show nothing (file was written)
|
||||
if (!expanded) {
|
||||
return new Text("", 0, 0);
|
||||
@@ -218,14 +218,14 @@ export default function (pi: ExtensionAPI) {
|
||||
return tools.edit.execute(toolCallId, params, signal, onUpdate);
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
const path = shortenPath(args.path || "");
|
||||
const pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
|
||||
|
||||
return new Text(`${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
renderResult(result, { expanded }, theme, _context) {
|
||||
// Minimal mode: show nothing in collapsed state
|
||||
if (!expanded) {
|
||||
return new Text("", 0, 0);
|
||||
@@ -263,7 +263,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return tools.find.execute(toolCallId, params, signal, onUpdate);
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
const pattern = args.pattern || "";
|
||||
const path = shortenPath(args.path || ".");
|
||||
const limit = args.limit;
|
||||
@@ -277,7 +277,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
renderResult(result, { expanded }, theme, _context) {
|
||||
if (!expanded) {
|
||||
// Minimal: just show count
|
||||
const textContent = result.content.find((c) => c.type === "text");
|
||||
@@ -321,7 +321,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return tools.grep.execute(toolCallId, params, signal, onUpdate);
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
const pattern = args.pattern || "";
|
||||
const path = shortenPath(args.path || ".");
|
||||
const glob = args.glob;
|
||||
@@ -339,7 +339,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
renderResult(result, { expanded }, theme, _context) {
|
||||
if (!expanded) {
|
||||
// Minimal: just show match count
|
||||
const textContent = result.content.find((c) => c.type === "text");
|
||||
@@ -383,7 +383,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return tools.ls.execute(toolCallId, params, signal, onUpdate);
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
const path = shortenPath(args.path || ".");
|
||||
const limit = args.limit;
|
||||
|
||||
@@ -395,7 +395,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
renderResult(result, { expanded }, theme, _context) {
|
||||
if (!expanded) {
|
||||
// Minimal: just show entry count
|
||||
const textContent = result.content.find((c) => c.type === "text");
|
||||
|
||||
@@ -227,7 +227,7 @@ export default function question(pi: ExtensionAPI) {
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question);
|
||||
const opts = Array.isArray(args.options) ? args.options : [];
|
||||
if (opts.length) {
|
||||
@@ -238,7 +238,7 @@ export default function question(pi: ExtensionAPI) {
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme) {
|
||||
renderResult(result, _options, theme, _context) {
|
||||
const details = result.details as QuestionDetails | undefined;
|
||||
if (!details) {
|
||||
const text = result.content[0];
|
||||
|
||||
@@ -393,7 +393,7 @@ export default function questionnaire(pi: ExtensionAPI) {
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
const qs = (args.questions as Question[]) || [];
|
||||
const count = qs.length;
|
||||
const labels = qs.map((q) => q.label || q.id).join(", ");
|
||||
@@ -405,7 +405,7 @@ export default function questionnaire(pi: ExtensionAPI) {
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme) {
|
||||
renderResult(result, _options, theme, _context) {
|
||||
const details = result.details as QuestionnaireResult | undefined;
|
||||
if (!details) {
|
||||
const text = result.content[0];
|
||||
|
||||
@@ -668,7 +668,7 @@ export default function (pi: ExtensionAPI) {
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
const scope: AgentScope = args.agentScope ?? "user";
|
||||
if (args.chain && args.chain.length > 0) {
|
||||
let text =
|
||||
@@ -712,7 +712,7 @@ export default function (pi: ExtensionAPI) {
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
renderResult(result, { expanded }, theme, _context) {
|
||||
const details = result.details as SubagentDetails | undefined;
|
||||
if (!details || details.results.length === 0) {
|
||||
const text = result.content[0];
|
||||
|
||||
@@ -220,14 +220,14 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
|
||||
if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
|
||||
if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
renderResult(result, { expanded }, theme, _context) {
|
||||
const details = result.details as TodoDetails | undefined;
|
||||
if (!details) {
|
||||
const text = result.content[0];
|
||||
|
||||
@@ -135,7 +135,7 @@ export default function (pi: ExtensionAPI) {
|
||||
},
|
||||
|
||||
// Custom rendering of the tool call (shown before/during execution)
|
||||
renderCall(args, theme) {
|
||||
renderCall(args, theme, _context) {
|
||||
let text = theme.fg("toolTitle", theme.bold("rg "));
|
||||
text += theme.fg("accent", `"${args.pattern}"`);
|
||||
if (args.path) {
|
||||
@@ -148,7 +148,7 @@ export default function (pi: ExtensionAPI) {
|
||||
},
|
||||
|
||||
// Custom rendering of the tool result
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
renderResult(result, { expanded, isPartial }, theme, _context) {
|
||||
const details = result.details as RgDetails | undefined;
|
||||
|
||||
// Handle streaming/partial results
|
||||
|
||||
@@ -78,7 +78,8 @@ import type { SettingsManager } from "./settings-manager.js";
|
||||
import { BUILTIN_SLASH_COMMANDS, type SlashCommandInfo, type SlashCommandLocation } from "./slash-commands.js";
|
||||
import { buildSystemPrompt } from "./system-prompt.js";
|
||||
import type { BashOperations } from "./tools/bash.js";
|
||||
import { createAllTools } from "./tools/index.js";
|
||||
import { createAllToolDefinitions } from "./tools/index.js";
|
||||
import { createToolDefinitionFromAgentTool, wrapToolDefinition } from "./tools/tool-definition-wrapper.js";
|
||||
|
||||
// ============================================================================
|
||||
// Skill Block Parsing
|
||||
@@ -143,7 +144,12 @@ export interface AgentSessionConfig {
|
||||
modelRegistry: ModelRegistry;
|
||||
/** Initial active built-in tool names. Default: [read, bash, edit, write] */
|
||||
initialActiveToolNames?: string[];
|
||||
/** Override base tools (useful for custom runtimes). */
|
||||
/**
|
||||
* Override base tools (useful for custom runtimes).
|
||||
*
|
||||
* These are synthesized into minimal ToolDefinitions internally so AgentSession can keep
|
||||
* a definition-first registry even when callers provide plain AgentTool instances.
|
||||
*/
|
||||
baseToolsOverride?: Record<string, AgentTool>;
|
||||
/** Mutable ref used by Agent to access the current ExtensionRunner */
|
||||
extensionRunnerRef?: { current?: ExtensionRunner };
|
||||
@@ -252,7 +258,7 @@ export class AgentSession {
|
||||
|
||||
private _resourceLoader: ResourceLoader;
|
||||
private _customTools: ToolDefinition[];
|
||||
private _baseToolRegistry: Map<string, AgentTool> = new Map();
|
||||
private _baseToolDefinitions: Map<string, ToolDefinition> = new Map();
|
||||
private _cwd: string;
|
||||
private _extensionRunnerRef?: { current?: ExtensionRunner };
|
||||
private _initialActiveToolNames?: string[];
|
||||
@@ -268,6 +274,7 @@ export class AgentSession {
|
||||
|
||||
// Tool registry for extension getTools/setTools
|
||||
private _toolRegistry: Map<string, AgentTool> = new Map();
|
||||
private _toolDefinitions: Map<string, ToolDefinition> = new Map();
|
||||
private _toolPromptSnippets: Map<string, string> = new Map();
|
||||
private _toolPromptGuidelines: Map<string, string[]> = new Map();
|
||||
|
||||
@@ -706,13 +713,17 @@ export class AgentSession {
|
||||
* Get all configured tools with name, description, and parameter schema.
|
||||
*/
|
||||
getAllTools(): ToolInfo[] {
|
||||
return Array.from(this._toolRegistry.values()).map((t) => ({
|
||||
return Array.from(this._toolDefinitions.values()).map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
}));
|
||||
}
|
||||
|
||||
getToolDefinition(name: string): ToolDefinition | undefined {
|
||||
return this._toolDefinitions.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active tools by name.
|
||||
* Only tools in the registry can be enabled. Unknown tool names are ignored.
|
||||
@@ -2202,19 +2213,24 @@ export class AgentSession {
|
||||
...registeredTools,
|
||||
...this._customTools.map((def) => ({ definition: def, extensionPath: "<sdk>" })),
|
||||
];
|
||||
const definitionRegistry = new Map(this._baseToolDefinitions);
|
||||
for (const { definition } of allCustomTools) {
|
||||
definitionRegistry.set(definition.name, definition);
|
||||
}
|
||||
this._toolDefinitions = definitionRegistry;
|
||||
this._toolPromptSnippets = new Map(
|
||||
allCustomTools
|
||||
.map((registeredTool) => {
|
||||
const snippet = this._normalizePromptSnippet(registeredTool.definition.promptSnippet);
|
||||
return snippet ? ([registeredTool.definition.name, snippet] as const) : undefined;
|
||||
Array.from(definitionRegistry.values())
|
||||
.map((definition) => {
|
||||
const snippet = this._normalizePromptSnippet(definition.promptSnippet);
|
||||
return snippet ? ([definition.name, snippet] as const) : undefined;
|
||||
})
|
||||
.filter((entry): entry is readonly [string, string] => entry !== undefined),
|
||||
);
|
||||
this._toolPromptGuidelines = new Map(
|
||||
allCustomTools
|
||||
.map((registeredTool) => {
|
||||
const guidelines = this._normalizePromptGuidelines(registeredTool.definition.promptGuidelines);
|
||||
return guidelines.length > 0 ? ([registeredTool.definition.name, guidelines] as const) : undefined;
|
||||
Array.from(definitionRegistry.values())
|
||||
.map((definition) => {
|
||||
const guidelines = this._normalizePromptGuidelines(definition.promptGuidelines);
|
||||
return guidelines.length > 0 ? ([definition.name, guidelines] as const) : undefined;
|
||||
})
|
||||
.filter((entry): entry is readonly [string, string[]] => entry !== undefined),
|
||||
);
|
||||
@@ -2222,7 +2238,12 @@ export class AgentSession {
|
||||
? wrapRegisteredTools(allCustomTools, this._extensionRunner)
|
||||
: [];
|
||||
|
||||
const toolRegistry = new Map(this._baseToolRegistry);
|
||||
const toolRegistry = new Map(
|
||||
Array.from(this._baseToolDefinitions.values()).map((definition) => [
|
||||
definition.name,
|
||||
wrapToolDefinition(definition),
|
||||
]),
|
||||
);
|
||||
for (const tool of wrappedExtensionTools as AgentTool[]) {
|
||||
toolRegistry.set(tool.name, tool);
|
||||
}
|
||||
@@ -2254,14 +2275,21 @@ export class AgentSession {
|
||||
}): void {
|
||||
const autoResizeImages = this.settingsManager.getImageAutoResize();
|
||||
const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();
|
||||
const baseTools = this._baseToolsOverride
|
||||
? this._baseToolsOverride
|
||||
: createAllTools(this._cwd, {
|
||||
const baseToolDefinitions = this._baseToolsOverride
|
||||
? Object.fromEntries(
|
||||
Object.entries(this._baseToolsOverride).map(([name, tool]) => [
|
||||
name,
|
||||
createToolDefinitionFromAgentTool(tool),
|
||||
]),
|
||||
)
|
||||
: createAllToolDefinitions(this._cwd, {
|
||||
read: { autoResizeImages },
|
||||
bash: { commandPrefix: shellCommandPrefix },
|
||||
});
|
||||
|
||||
this._baseToolRegistry = new Map(Object.entries(baseTools).map(([name, tool]) => [name, tool as AgentTool]));
|
||||
this._baseToolDefinitions = new Map(
|
||||
Object.entries(baseToolDefinitions).map(([name, tool]) => [name, tool as ToolDefinition]),
|
||||
);
|
||||
|
||||
const extensionsResult = this._resourceLoader.getExtensions();
|
||||
if (options.flagValues) {
|
||||
@@ -3052,13 +3080,10 @@ export class AgentSession {
|
||||
const themeName = this.settingsManager.getTheme();
|
||||
|
||||
// Create tool renderer if we have an extension runner (for custom tool HTML rendering)
|
||||
let toolRenderer: ToolHtmlRenderer | undefined;
|
||||
if (this._extensionRunner) {
|
||||
toolRenderer = createToolHtmlRenderer({
|
||||
getToolDefinition: (name) => this._extensionRunner!.getToolDefinition(name),
|
||||
theme,
|
||||
});
|
||||
}
|
||||
const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({
|
||||
getToolDefinition: (name) => this.getToolDefinition(name),
|
||||
theme,
|
||||
});
|
||||
|
||||
return await exportSessionToHtml(this.sessionManager, this.state, {
|
||||
outputPath,
|
||||
|
||||
@@ -13,9 +13,10 @@ import { SessionManager } from "../session-manager.js";
|
||||
*/
|
||||
export interface ToolHtmlRenderer {
|
||||
/** Render a tool call to HTML. Returns undefined if tool has no custom renderer. */
|
||||
renderCall(toolName: string, args: unknown): string | undefined;
|
||||
renderCall(toolCallId: string, toolName: string, args: unknown): string | undefined;
|
||||
/** Render a tool result to HTML. Returns collapsed/expanded or undefined if tool has no custom renderer. */
|
||||
renderResult(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
result: Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
|
||||
details: unknown,
|
||||
@@ -191,7 +192,7 @@ function preRenderCustomTools(
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "toolCall" && !BUILTIN_TOOLS.has(block.name)) {
|
||||
const callHtml = toolRenderer.renderCall(block.name, block.arguments);
|
||||
const callHtml = toolRenderer.renderCall(block.id, block.name, block.arguments);
|
||||
if (callHtml) {
|
||||
renderedTools[block.id] = { callHtml };
|
||||
}
|
||||
@@ -205,7 +206,13 @@ function preRenderCustomTools(
|
||||
// Only render if we have a pre-rendered call OR it's not a built-in tool
|
||||
const existing = renderedTools[msg.toolCallId];
|
||||
if (existing || !BUILTIN_TOOLS.has(toolName)) {
|
||||
const rendered = toolRenderer.renderResult(toolName, msg.content, msg.details, msg.isError || false);
|
||||
const rendered = toolRenderer.renderResult(
|
||||
msg.toolCallId,
|
||||
toolName,
|
||||
msg.content,
|
||||
msg.details,
|
||||
msg.isError || false,
|
||||
);
|
||||
if (rendered) {
|
||||
renderedTools[msg.toolCallId] = {
|
||||
...existing,
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
*/
|
||||
|
||||
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import type { Theme } from "../../modes/interactive/theme/theme.js";
|
||||
import type { ToolDefinition } from "../extensions/types.js";
|
||||
import type { ToolDefinition, ToolRenderContext } from "../extensions/types.js";
|
||||
import { ansiLinesToHtml } from "./ansi-to-html.js";
|
||||
|
||||
export interface ToolHtmlRendererDeps {
|
||||
@@ -21,9 +22,10 @@ export interface ToolHtmlRendererDeps {
|
||||
|
||||
export interface ToolHtmlRenderer {
|
||||
/** Render a tool call to HTML. Returns undefined if tool has no custom renderer. */
|
||||
renderCall(toolName: string, args: unknown): string | undefined;
|
||||
renderCall(toolCallId: string, toolName: string, args: unknown): string | undefined;
|
||||
/** Render a tool result to collapsed/expanded HTML. Returns undefined if tool has no custom renderer. */
|
||||
renderResult(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
result: Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
|
||||
details: unknown,
|
||||
@@ -40,27 +42,68 @@ export interface ToolHtmlRenderer {
|
||||
export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRenderer {
|
||||
const { getToolDefinition, theme, width = 100 } = deps;
|
||||
|
||||
const renderedCallComponents = new Map<string, Component>();
|
||||
const renderedResultComponents = new Map<string, Component>();
|
||||
const renderedStates = new Map<string, any>();
|
||||
const renderedArgs = new Map<string, unknown>();
|
||||
|
||||
const getState = (toolCallId: string): any => {
|
||||
let state = renderedStates.get(toolCallId);
|
||||
if (!state) {
|
||||
state = {};
|
||||
renderedStates.set(toolCallId, state);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
const createRenderContext = (
|
||||
toolCallId: string,
|
||||
lastComponent: Component | undefined,
|
||||
expanded: boolean,
|
||||
isPartial: boolean,
|
||||
isError: boolean,
|
||||
): ToolRenderContext => {
|
||||
return {
|
||||
args: renderedArgs.get(toolCallId),
|
||||
toolCallId,
|
||||
invalidate: () => {},
|
||||
lastComponent,
|
||||
state: getState(toolCallId),
|
||||
cwd: process.cwd(),
|
||||
executionStarted: true,
|
||||
argsComplete: true,
|
||||
isPartial,
|
||||
expanded,
|
||||
showImages: false,
|
||||
isError,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
renderCall(toolName: string, args: unknown): string | undefined {
|
||||
renderCall(toolCallId: string, toolName: string, args: unknown): string | undefined {
|
||||
try {
|
||||
renderedArgs.set(toolCallId, args);
|
||||
const toolDef = getToolDefinition(toolName);
|
||||
if (!toolDef?.renderCall) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const component = toolDef.renderCall(args, theme);
|
||||
if (!component) {
|
||||
return undefined;
|
||||
}
|
||||
const component = toolDef.renderCall(
|
||||
args,
|
||||
theme,
|
||||
createRenderContext(toolCallId, renderedCallComponents.get(toolCallId), false, true, false),
|
||||
);
|
||||
renderedCallComponents.set(toolCallId, component);
|
||||
const lines = component.render(width);
|
||||
return ansiLinesToHtml(lines);
|
||||
} catch {
|
||||
// On error, return undefined to trigger JSON fallback
|
||||
// On error, return undefined so HTML export can fall back to structured result rendering
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
renderResult(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
result: Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
|
||||
details: unknown,
|
||||
@@ -85,28 +128,27 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend
|
||||
agentToolResult,
|
||||
{ expanded: false, isPartial: false },
|
||||
theme,
|
||||
createRenderContext(toolCallId, renderedResultComponents.get(toolCallId), false, false, isError),
|
||||
);
|
||||
const collapsed = collapsedComponent ? ansiLinesToHtml(collapsedComponent.render(width)) : undefined;
|
||||
renderedResultComponents.set(toolCallId, collapsedComponent);
|
||||
const collapsed = ansiLinesToHtml(collapsedComponent.render(width));
|
||||
|
||||
// Render expanded
|
||||
const expandedComponent = toolDef.renderResult(
|
||||
agentToolResult,
|
||||
{ expanded: true, isPartial: false },
|
||||
theme,
|
||||
createRenderContext(toolCallId, renderedResultComponents.get(toolCallId), true, false, isError),
|
||||
);
|
||||
const expanded = expandedComponent ? ansiLinesToHtml(expandedComponent.render(width)) : undefined;
|
||||
|
||||
// Return collapsed only if it exists and differs from expanded
|
||||
if (!expanded) {
|
||||
return undefined;
|
||||
}
|
||||
renderedResultComponents.set(toolCallId, expandedComponent);
|
||||
const expanded = ansiLinesToHtml(expandedComponent.render(width));
|
||||
|
||||
return {
|
||||
...(collapsed && collapsed !== expanded ? { collapsed } : {}),
|
||||
expanded,
|
||||
};
|
||||
} catch {
|
||||
// On error, return undefined to trigger JSON fallback
|
||||
// On error, return undefined so HTML export can fall back to structured result rendering
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -329,10 +329,38 @@ export interface ToolRenderResultOptions {
|
||||
isPartial: boolean;
|
||||
}
|
||||
|
||||
/** Context passed to tool renderers. */
|
||||
export interface ToolRenderContext<TState = any, TArgs = any> {
|
||||
/** Current tool call arguments. Shared across call/result renders for the same tool call. */
|
||||
args: TArgs;
|
||||
/** Unique id for this tool execution. Stable across call/result renders for the same tool call. */
|
||||
toolCallId: string;
|
||||
/** Invalidate just this tool execution component for redraw. */
|
||||
invalidate: () => void;
|
||||
/** Previously returned component for this render slot, if any. */
|
||||
lastComponent: Component | undefined;
|
||||
/** Shared renderer state for this tool row. Initialized by tool-execution.ts. */
|
||||
state: TState;
|
||||
/** Working directory for this tool execution. */
|
||||
cwd: string;
|
||||
/** Whether the tool execution has started. */
|
||||
executionStarted: boolean;
|
||||
/** Whether the tool call arguments are complete. */
|
||||
argsComplete: boolean;
|
||||
/** Whether the tool result is partial/streaming. */
|
||||
isPartial: boolean;
|
||||
/** Whether the result view is expanded. */
|
||||
expanded: boolean;
|
||||
/** Whether inline images are currently shown in the TUI. */
|
||||
showImages: boolean;
|
||||
/** Whether the current result is an error. */
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool definition for registerTool().
|
||||
*/
|
||||
export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = unknown> {
|
||||
export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = unknown, TState = any> {
|
||||
/** Tool name (used in LLM tool calls) */
|
||||
name: string;
|
||||
/** Human-readable label for UI */
|
||||
@@ -356,14 +384,15 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
|
||||
): Promise<AgentToolResult<TDetails>>;
|
||||
|
||||
/** Custom rendering for tool call display */
|
||||
renderCall?: (args: Static<TParams>, theme: Theme) => Component | undefined;
|
||||
renderCall?: (args: Static<TParams>, theme: Theme, context: ToolRenderContext<TState, Static<TParams>>) => Component;
|
||||
|
||||
/** Custom rendering for tool result display */
|
||||
renderResult?: (
|
||||
result: AgentToolResult<TDetails>,
|
||||
options: ToolRenderResultOptions,
|
||||
theme: Theme,
|
||||
) => Component | undefined;
|
||||
context: ToolRenderContext<TState, Static<TParams>>,
|
||||
) => Component;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -997,7 +1026,9 @@ export interface ExtensionAPI {
|
||||
// =========================================================================
|
||||
|
||||
/** Register a tool that the LLM can call. */
|
||||
registerTool<TParams extends TSchema = TSchema, TDetails = unknown>(tool: ToolDefinition<TParams, TDetails>): void;
|
||||
registerTool<TParams extends TSchema = TSchema, TDetails = unknown, TState = any>(
|
||||
tool: ToolDefinition<TParams, TDetails, TState>,
|
||||
): void;
|
||||
|
||||
// =========================================================================
|
||||
// Command, Shortcut, Flag Registration
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { wrapToolDefinition } from "../tools/tool-definition-wrapper.js";
|
||||
import type { ExtensionRunner } from "./runner.js";
|
||||
import type { RegisteredTool } from "./types.js";
|
||||
|
||||
@@ -14,15 +15,7 @@ import type { RegisteredTool } from "./types.js";
|
||||
* Uses the runner's createContext() for consistent context across tools and event handlers.
|
||||
*/
|
||||
export function wrapRegisteredTool(registeredTool: RegisteredTool, runner: ExtensionRunner): AgentTool {
|
||||
const { definition } = registeredTool;
|
||||
return {
|
||||
name: definition.name,
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
parameters: definition.parameters,
|
||||
execute: (toolCallId, params, signal, onUpdate) =>
|
||||
definition.execute(toolCallId, params, signal, onUpdate, runner.createContext()),
|
||||
};
|
||||
return wrapToolDefinition(registeredTool.definition, () => runner.createContext());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,17 +5,6 @@
|
||||
import { getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
|
||||
import { formatSkillsForPrompt, type Skill } from "./skills.js";
|
||||
|
||||
/** Tool descriptions for system prompt */
|
||||
const toolDescriptions: Record<string, string> = {
|
||||
read: "Read file contents",
|
||||
bash: "Execute bash commands (ls, grep, find, etc.)",
|
||||
edit: "Make surgical edits to files (find exact text and replace)",
|
||||
write: "Create or overwrite files",
|
||||
grep: "Search file contents for patterns (respects .gitignore)",
|
||||
find: "Find files by glob pattern (respects .gitignore)",
|
||||
ls: "List directory contents",
|
||||
};
|
||||
|
||||
export interface BuildSystemPromptOptions {
|
||||
/** Custom system prompt (replaces default). */
|
||||
customPrompt?: string;
|
||||
@@ -92,18 +81,11 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
||||
const examplesPath = getExamplesPath();
|
||||
|
||||
// Build tools list based on selected tools.
|
||||
// Built-ins use toolDescriptions. Custom tools can provide one-line snippets.
|
||||
// A tool appears in Available tools only when the caller provides a one-line snippet.
|
||||
const tools = selectedTools || ["read", "bash", "edit", "write"];
|
||||
const visibleTools = tools.filter((name) => name in toolDescriptions || toolSnippets?.[name]);
|
||||
const visibleTools = tools.filter((name) => !!toolSnippets?.[name]);
|
||||
const toolsList =
|
||||
visibleTools.length > 0
|
||||
? visibleTools
|
||||
.map((name) => {
|
||||
const snippet = toolSnippets?.[name] ?? toolDescriptions[name] ?? name;
|
||||
return `- ${name}: ${snippet}`;
|
||||
})
|
||||
.join("\n")
|
||||
: "(none)";
|
||||
visibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets![name]}`).join("\n") : "(none)";
|
||||
|
||||
// Build guidelines based on which tools are actually available
|
||||
const guidelinesList: string[] = [];
|
||||
@@ -117,8 +99,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
||||
};
|
||||
|
||||
const hasBash = tools.includes("bash");
|
||||
const hasEdit = tools.includes("edit");
|
||||
const hasWrite = tools.includes("write");
|
||||
const hasGrep = tools.includes("grep");
|
||||
const hasFind = tools.includes("find");
|
||||
const hasLs = tools.includes("ls");
|
||||
@@ -131,28 +111,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
||||
addGuideline("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)");
|
||||
}
|
||||
|
||||
// Read before edit guideline
|
||||
if (hasRead && hasEdit) {
|
||||
addGuideline("Use read to examine files before editing. You must use this tool instead of cat or sed.");
|
||||
}
|
||||
|
||||
// Edit guideline
|
||||
if (hasEdit) {
|
||||
addGuideline("Use edit for precise changes (old text must match exactly)");
|
||||
}
|
||||
|
||||
// Write guideline
|
||||
if (hasWrite) {
|
||||
addGuideline("Use write only for new files or complete rewrites");
|
||||
}
|
||||
|
||||
// Output guideline (only when actually writing or executing)
|
||||
if (hasEdit || hasWrite) {
|
||||
addGuideline(
|
||||
"When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did",
|
||||
);
|
||||
}
|
||||
|
||||
for (const guideline of promptGuidelines ?? []) {
|
||||
const normalized = guideline.trim();
|
||||
if (normalized.length > 0) {
|
||||
|
||||
@@ -3,14 +3,21 @@ 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 { Container, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { spawn } from "child_process";
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
||||
import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.js";
|
||||
import { theme } from "../../modes/interactive/theme/theme.js";
|
||||
import { waitForChildProcess } from "../../utils/child-process.js";
|
||||
import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js";
|
||||
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
|
||||
import { getTextOutput, invalidArgText, str } from "./render-utils.js";
|
||||
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
|
||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
|
||||
|
||||
/**
|
||||
* Generate a unique temp file path for bash output
|
||||
* Generate a unique temp file path for bash output.
|
||||
*/
|
||||
function getTempFilePath(): string {
|
||||
const id = randomBytes(8).toString("hex");
|
||||
@@ -31,14 +38,14 @@ export interface BashToolDetails {
|
||||
|
||||
/**
|
||||
* Pluggable operations for the bash tool.
|
||||
* Override these to delegate command execution to remote systems (e.g., SSH).
|
||||
* Override these to delegate command execution to remote systems (for example SSH).
|
||||
*/
|
||||
export interface BashOperations {
|
||||
/**
|
||||
* Execute a command and stream output.
|
||||
* @param command - The command to execute
|
||||
* @param cwd - Working directory
|
||||
* @param options - Execution options
|
||||
* @param command The command to execute
|
||||
* @param cwd Working directory
|
||||
* @param options Execution options
|
||||
* @returns Promise resolving to exit code (null if killed)
|
||||
*/
|
||||
exec: (
|
||||
@@ -56,81 +63,58 @@ export interface BashOperations {
|
||||
/**
|
||||
* Create bash operations using pi's built-in local shell execution backend.
|
||||
*
|
||||
* This is useful for extensions that intercept user_bash and want to keep
|
||||
* pi's standard local shell behavior while still wrapping or rewriting
|
||||
* commands before execution.
|
||||
* This is useful for extensions that intercept user_bash and still want pi's
|
||||
* standard local shell behavior while wrapping or rewriting commands.
|
||||
*/
|
||||
export function createLocalBashOperations(): BashOperations {
|
||||
return {
|
||||
exec: (command, cwd, { onData, signal, timeout, env }) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { shell, args } = getShellConfig();
|
||||
|
||||
if (!existsSync(cwd)) {
|
||||
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(shell, [...args, command], {
|
||||
cwd,
|
||||
detached: true,
|
||||
env: env ?? getShellEnv(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
|
||||
// Set timeout if provided
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
// Set timeout if provided.
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (child.pid) {
|
||||
killProcessTree(child.pid);
|
||||
}
|
||||
if (child.pid) killProcessTree(child.pid);
|
||||
}, timeout * 1000);
|
||||
}
|
||||
|
||||
// Stream stdout and stderr
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", onData);
|
||||
}
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", onData);
|
||||
}
|
||||
|
||||
// Handle abort signal - kill entire process tree
|
||||
// Stream stdout and stderr.
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", onData);
|
||||
// Handle abort signal by killing the entire process tree.
|
||||
const onAbort = () => {
|
||||
if (child.pid) {
|
||||
killProcessTree(child.pid);
|
||||
}
|
||||
if (child.pid) killProcessTree(child.pid);
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
// Handle shell spawn errors and wait for the process to terminate without hanging
|
||||
// on inherited stdio handles held by detached descendants.
|
||||
waitForChildProcess(child)
|
||||
.then((code) => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
reject(new Error(`timeout:${timeout}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({ exitCode: code });
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -152,85 +136,182 @@ export interface BashSpawnContext {
|
||||
export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext;
|
||||
|
||||
function resolveSpawnContext(command: string, cwd: string, spawnHook?: BashSpawnHook): BashSpawnContext {
|
||||
const baseContext: BashSpawnContext = {
|
||||
command,
|
||||
cwd,
|
||||
env: { ...getShellEnv() },
|
||||
};
|
||||
|
||||
const baseContext: BashSpawnContext = { command, cwd, env: { ...getShellEnv() } };
|
||||
return spawnHook ? spawnHook(baseContext) : baseContext;
|
||||
}
|
||||
|
||||
export interface BashToolOptions {
|
||||
/** Custom operations for command execution. Default: local shell */
|
||||
operations?: BashOperations;
|
||||
/** Command prefix prepended to every command (e.g., "shopt -s expand_aliases" for alias support) */
|
||||
/** Command prefix prepended to every command (for example shell setup commands) */
|
||||
commandPrefix?: string;
|
||||
/** Hook to adjust command, cwd, or env before execution */
|
||||
spawnHook?: BashSpawnHook;
|
||||
}
|
||||
|
||||
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
|
||||
const BASH_PREVIEW_LINES = 5;
|
||||
|
||||
type BashRenderState = {
|
||||
startedAt: number | undefined;
|
||||
endedAt: number | undefined;
|
||||
interval: NodeJS.Timeout | undefined;
|
||||
};
|
||||
|
||||
type BashResultRenderState = {
|
||||
cachedWidth: number | undefined;
|
||||
cachedLines: string[] | undefined;
|
||||
cachedSkipped: number | undefined;
|
||||
};
|
||||
|
||||
class BashResultRenderComponent extends Container {
|
||||
state: BashResultRenderState = {
|
||||
cachedWidth: undefined,
|
||||
cachedLines: undefined,
|
||||
cachedSkipped: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function formatBashCall(args: { command?: string; timeout?: number } | undefined): string {
|
||||
const command = str(args?.command);
|
||||
const timeout = args?.timeout as number | undefined;
|
||||
const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
|
||||
const commandDisplay = command === null ? invalidArgText(theme) : command ? command : theme.fg("toolOutput", "...");
|
||||
return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix;
|
||||
}
|
||||
|
||||
function rebuildBashResultRenderComponent(
|
||||
component: BashResultRenderComponent,
|
||||
result: {
|
||||
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||
details?: BashToolDetails;
|
||||
},
|
||||
options: ToolRenderResultOptions,
|
||||
showImages: boolean,
|
||||
startedAt: number | undefined,
|
||||
endedAt: number | undefined,
|
||||
): void {
|
||||
const state = component.state;
|
||||
component.clear();
|
||||
|
||||
const output = getTextOutput(result as any, showImages).trim();
|
||||
|
||||
if (output) {
|
||||
const styledOutput = output
|
||||
.split("\n")
|
||||
.map((line) => theme.fg("toolOutput", line))
|
||||
.join("\n");
|
||||
|
||||
if (options.expanded) {
|
||||
component.addChild(new Text(`\n${styledOutput}`, 0, 0));
|
||||
} else {
|
||||
component.addChild({
|
||||
render: (width: number) => {
|
||||
if (state.cachedLines === undefined || state.cachedWidth !== width) {
|
||||
const preview = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, width);
|
||||
state.cachedLines = preview.visualLines;
|
||||
state.cachedSkipped = preview.skippedCount;
|
||||
state.cachedWidth = width;
|
||||
}
|
||||
if (state.cachedSkipped && state.cachedSkipped > 0) {
|
||||
const hint =
|
||||
theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) +
|
||||
` ${keyHint("app.tools.expand", "to expand")})`;
|
||||
return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])];
|
||||
}
|
||||
return ["", ...(state.cachedLines ?? [])];
|
||||
},
|
||||
invalidate: () => {
|
||||
state.cachedWidth = undefined;
|
||||
state.cachedLines = undefined;
|
||||
state.cachedSkipped = undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const truncation = result.details?.truncation;
|
||||
const fullOutputPath = result.details?.fullOutputPath;
|
||||
if (truncation?.truncated || fullOutputPath) {
|
||||
const warnings: string[] = [];
|
||||
if (fullOutputPath) {
|
||||
warnings.push(`Full output: ${fullOutputPath}`);
|
||||
}
|
||||
if (truncation?.truncated) {
|
||||
if (truncation.truncatedBy === "lines") {
|
||||
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
|
||||
} else {
|
||||
warnings.push(
|
||||
`Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
component.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0));
|
||||
}
|
||||
|
||||
if (startedAt !== undefined) {
|
||||
const label = options.isPartial ? "Elapsed" : "Took";
|
||||
const endTime = endedAt ?? Date.now();
|
||||
component.addChild(new Text(`\n${theme.fg("muted", `${label} ${formatDuration(endTime - startedAt)}`)}`, 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
export function createBashToolDefinition(
|
||||
cwd: string,
|
||||
options?: BashToolOptions,
|
||||
): ToolDefinition<typeof bashSchema, BashToolDetails | undefined, BashRenderState> {
|
||||
const ops = options?.operations ?? createLocalBashOperations();
|
||||
const commandPrefix = options?.commandPrefix;
|
||||
const spawnHook = options?.spawnHook;
|
||||
|
||||
return {
|
||||
name: "bash",
|
||||
label: "bash",
|
||||
description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
|
||||
promptSnippet: "Execute bash commands (ls, grep, find, etc.)",
|
||||
parameters: bashSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
async execute(
|
||||
_toolCallId,
|
||||
{ command, timeout }: { command: string; timeout?: number },
|
||||
signal?: AbortSignal,
|
||||
onUpdate?,
|
||||
) => {
|
||||
// Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
|
||||
_ctx?,
|
||||
) {
|
||||
const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
|
||||
const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook);
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate({ content: [], details: undefined });
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
// We'll stream to a temp file if output gets large
|
||||
let tempFilePath: string | undefined;
|
||||
let tempFileStream: ReturnType<typeof createWriteStream> | undefined;
|
||||
let totalBytes = 0;
|
||||
|
||||
// Keep a rolling buffer of the last chunk for tail truncation
|
||||
const chunks: Buffer[] = [];
|
||||
let chunksBytes = 0;
|
||||
// Keep more than we need so we have enough for truncation
|
||||
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
|
||||
|
||||
const handleData = (data: Buffer) => {
|
||||
totalBytes += data.length;
|
||||
|
||||
// Start writing to temp file once we exceed the threshold
|
||||
// Start writing to a temp file once output exceeds the in-memory threshold.
|
||||
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
|
||||
tempFilePath = getTempFilePath();
|
||||
tempFileStream = createWriteStream(tempFilePath);
|
||||
// Write all buffered chunks to the file
|
||||
for (const chunk of chunks) {
|
||||
tempFileStream.write(chunk);
|
||||
}
|
||||
// Write all buffered chunks to the file.
|
||||
for (const chunk of chunks) tempFileStream.write(chunk);
|
||||
}
|
||||
|
||||
// Write to temp file if we have one
|
||||
if (tempFileStream) {
|
||||
tempFileStream.write(data);
|
||||
}
|
||||
|
||||
// Keep rolling buffer of recent data
|
||||
// Write to temp file if we have one.
|
||||
if (tempFileStream) tempFileStream.write(data);
|
||||
// Keep a rolling buffer of recent output for tail truncation.
|
||||
chunks.push(data);
|
||||
chunksBytes += data.length;
|
||||
|
||||
// Trim old chunks if buffer is too large
|
||||
// Trim old chunks if the rolling buffer grows too large.
|
||||
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
|
||||
const removed = chunks.shift()!;
|
||||
chunksBytes -= removed.length;
|
||||
}
|
||||
|
||||
// Stream partial output to callback (truncated rolling buffer)
|
||||
// Stream partial output using the rolling tail buffer.
|
||||
if (onUpdate) {
|
||||
const fullBuffer = Buffer.concat(chunks);
|
||||
const fullText = fullBuffer.toString("utf-8");
|
||||
@@ -252,34 +333,22 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
|
||||
env: spawnContext.env,
|
||||
})
|
||||
.then(({ exitCode }) => {
|
||||
// Close temp file stream
|
||||
if (tempFileStream) {
|
||||
tempFileStream.end();
|
||||
}
|
||||
|
||||
// Combine all buffered chunks
|
||||
// Close temp file stream before building the final result.
|
||||
if (tempFileStream) tempFileStream.end();
|
||||
// Combine the rolling buffer chunks.
|
||||
const fullBuffer = Buffer.concat(chunks);
|
||||
const fullOutput = fullBuffer.toString("utf-8");
|
||||
|
||||
// Apply tail truncation
|
||||
// Apply tail truncation for the final display payload.
|
||||
const truncation = truncateTail(fullOutput);
|
||||
let outputText = truncation.content || "(no output)";
|
||||
|
||||
// Build details with truncation info
|
||||
let details: BashToolDetails | undefined;
|
||||
|
||||
if (truncation.truncated) {
|
||||
details = {
|
||||
truncation,
|
||||
fullOutputPath: tempFilePath,
|
||||
};
|
||||
|
||||
// Build actionable notice
|
||||
// Build truncation details and an actionable notice.
|
||||
details = { truncation, fullOutputPath: tempFilePath };
|
||||
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
||||
const endLine = truncation.totalLines;
|
||||
|
||||
if (truncation.lastLinePartial) {
|
||||
// Edge case: last line alone > 30KB
|
||||
// Edge case: the last line alone is larger than the byte limit.
|
||||
const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split("\n").pop() || "", "utf-8"));
|
||||
outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;
|
||||
} else if (truncation.truncatedBy === "lines") {
|
||||
@@ -288,7 +357,6 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
|
||||
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0 && exitCode !== null) {
|
||||
outputText += `\n\nCommand exited with code ${exitCode}`;
|
||||
reject(new Error(outputText));
|
||||
@@ -297,15 +365,10 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
// Close temp file stream
|
||||
if (tempFileStream) {
|
||||
tempFileStream.end();
|
||||
}
|
||||
|
||||
// Combine all buffered chunks for error output
|
||||
// Close temp file stream and include buffered output in the error message.
|
||||
if (tempFileStream) tempFileStream.end();
|
||||
const fullBuffer = Buffer.concat(chunks);
|
||||
let output = fullBuffer.toString("utf-8");
|
||||
|
||||
if (err.message === "aborted") {
|
||||
if (output) output += "\n\n";
|
||||
output += "Command aborted";
|
||||
@@ -321,8 +384,48 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
|
||||
});
|
||||
});
|
||||
},
|
||||
renderCall(args, _theme, context) {
|
||||
const state = context.state;
|
||||
if (context.executionStarted && state.startedAt === undefined) {
|
||||
state.startedAt = Date.now();
|
||||
state.endedAt = undefined;
|
||||
}
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
text.setText(formatBashCall(args));
|
||||
return text;
|
||||
},
|
||||
renderResult(result, options, _theme, context) {
|
||||
const state = context.state;
|
||||
if (state.startedAt !== undefined && options.isPartial && !state.interval) {
|
||||
state.interval = setInterval(() => context.invalidate(), 1000);
|
||||
}
|
||||
if (!options.isPartial || context.isError) {
|
||||
state.endedAt ??= Date.now();
|
||||
if (state.interval) {
|
||||
clearInterval(state.interval);
|
||||
state.interval = undefined;
|
||||
}
|
||||
}
|
||||
const component =
|
||||
(context.lastComponent as BashResultRenderComponent | undefined) ?? new BashResultRenderComponent();
|
||||
rebuildBashResultRenderComponent(
|
||||
component,
|
||||
result as any,
|
||||
options,
|
||||
context.showImages,
|
||||
state.startedAt,
|
||||
state.endedAt,
|
||||
);
|
||||
component.invalidate();
|
||||
return component;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Default bash tool using process.cwd() - for backwards compatibility */
|
||||
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
|
||||
return wrapToolDefinition(createBashToolDefinition(cwd, options));
|
||||
}
|
||||
|
||||
/** Default bash tool using process.cwd() for backwards compatibility. */
|
||||
export const bashToolDefinition = createBashToolDefinition(process.cwd());
|
||||
export const bashTool = createBashTool(process.cwd());
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Container, Text } from "@mariozechner/pi-tui";
|
||||
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 { renderDiff } from "../../modes/interactive/components/diff.js";
|
||||
import type { ToolDefinition } from "../extensions/types.js";
|
||||
import {
|
||||
computeEditDiff,
|
||||
detectLineEnding,
|
||||
type EditDiffError,
|
||||
type EditDiffResult,
|
||||
fuzzyFindText,
|
||||
generateDiffString,
|
||||
normalizeForFuzzyMatch,
|
||||
@@ -13,6 +19,13 @@ import {
|
||||
} from "./edit-diff.js";
|
||||
import { withFileMutationQueue } from "./file-mutation-queue.js";
|
||||
import { resolveToCwd } from "./path-utils.js";
|
||||
import { invalidArgText, shortenPath, str } from "./render-utils.js";
|
||||
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
|
||||
|
||||
type EditRenderState = {
|
||||
argsKey?: string;
|
||||
preview?: EditDiffResult | EditDiffError;
|
||||
};
|
||||
|
||||
const editSchema = Type.Object({
|
||||
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
|
||||
@@ -31,7 +44,7 @@ export interface EditToolDetails {
|
||||
|
||||
/**
|
||||
* Pluggable operations for the edit tool.
|
||||
* Override these to delegate file editing to remote systems (e.g., SSH).
|
||||
* Override these to delegate file editing to remote systems (for example SSH).
|
||||
*/
|
||||
export interface EditOperations {
|
||||
/** Read file contents as a Buffer */
|
||||
@@ -53,20 +66,75 @@ export interface EditToolOptions {
|
||||
operations?: EditOperations;
|
||||
}
|
||||
|
||||
export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool<typeof editSchema> {
|
||||
const ops = options?.operations ?? defaultEditOperations;
|
||||
function formatEditCall(
|
||||
args: { path?: string; file_path?: string; oldText?: string; newText?: string } | undefined,
|
||||
state: EditRenderState,
|
||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
||||
): string {
|
||||
const invalidArg = invalidArgText(theme);
|
||||
const rawPath = str(args?.file_path ?? args?.path);
|
||||
const path = rawPath !== null ? shortenPath(rawPath) : null;
|
||||
const pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
|
||||
let text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
|
||||
|
||||
if (state.preview) {
|
||||
if ("error" in state.preview) {
|
||||
text += `\n\n${theme.fg("error", state.preview.error)}`;
|
||||
} else if (state.preview.diff) {
|
||||
text += `\n\n${renderDiff(state.preview.diff, { filePath: rawPath ?? undefined })}`;
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function formatEditResult(
|
||||
args: { path?: string; file_path?: string; oldText?: string; newText?: string } | undefined,
|
||||
state: EditRenderState,
|
||||
result: {
|
||||
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||
details?: EditToolDetails;
|
||||
},
|
||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
||||
isError: boolean,
|
||||
): string | undefined {
|
||||
const rawPath = str(args?.file_path ?? args?.path);
|
||||
if (isError) {
|
||||
const errorText = result.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text || "")
|
||||
.join("\n");
|
||||
return errorText ? `\n${theme.fg("error", errorText)}` : undefined;
|
||||
}
|
||||
|
||||
const previewDiff = state.preview && !("error" in state.preview) ? state.preview.diff : undefined;
|
||||
const resultDiff = result.details?.diff;
|
||||
if (!resultDiff || resultDiff === previewDiff) {
|
||||
return undefined;
|
||||
}
|
||||
return `\n${renderDiff(resultDiff, { filePath: rawPath ?? undefined })}`;
|
||||
}
|
||||
|
||||
export function createEditToolDefinition(
|
||||
cwd: string,
|
||||
options?: EditToolOptions,
|
||||
): ToolDefinition<typeof editSchema, EditToolDetails | undefined, EditRenderState> {
|
||||
const ops = options?.operations ?? defaultEditOperations;
|
||||
return {
|
||||
name: "edit",
|
||||
label: "edit",
|
||||
description:
|
||||
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
|
||||
promptSnippet: "Make surgical edits to files (find exact text and replace)",
|
||||
promptGuidelines: ["Use edit for precise changes (old text must match exactly)."],
|
||||
parameters: editSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
async execute(
|
||||
_toolCallId,
|
||||
{ path, oldText, newText }: { path: string; oldText: string; newText: string },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
_onUpdate?,
|
||||
_ctx?,
|
||||
) {
|
||||
const absolutePath = resolveToCwd(path, cwd);
|
||||
|
||||
return withFileMutationQueue(
|
||||
@@ -76,7 +144,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details: EditToolDetails | undefined;
|
||||
}>((resolve, reject) => {
|
||||
// Check if already aborted
|
||||
// Check if already aborted.
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
@@ -84,7 +152,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
|
||||
|
||||
let aborted = false;
|
||||
|
||||
// Set up abort handler
|
||||
// Set up abort handler.
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new Error("Operation aborted"));
|
||||
@@ -94,10 +162,10 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
// Perform the edit operation
|
||||
// Perform the edit operation.
|
||||
(async () => {
|
||||
try {
|
||||
// Check if file exists
|
||||
// Check if file exists.
|
||||
try {
|
||||
await ops.access(absolutePath);
|
||||
} catch {
|
||||
@@ -108,21 +176,21 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if aborted before reading
|
||||
// Check if aborted before reading.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the file
|
||||
// Read the file.
|
||||
const buffer = await ops.readFile(absolutePath);
|
||||
const rawContent = buffer.toString("utf-8");
|
||||
|
||||
// Check if aborted after reading
|
||||
// Check if aborted after reading.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip BOM before matching (LLM won't include invisible BOM in oldText)
|
||||
// Strip BOM before matching. The model will not include an invisible BOM in oldText.
|
||||
const { bom, text: content } = stripBom(rawContent);
|
||||
|
||||
const originalEnding = detectLineEnding(content);
|
||||
@@ -130,7 +198,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
|
||||
const normalizedOldText = normalizeToLF(oldText);
|
||||
const normalizedNewText = normalizeToLF(newText);
|
||||
|
||||
// Find the old text using fuzzy matching (tries exact match first, then fuzzy)
|
||||
// Find the old text using fuzzy matching. This tries exact match first, then a normalized fallback.
|
||||
const matchResult = fuzzyFindText(normalizedContent, normalizedOldText);
|
||||
|
||||
if (!matchResult.found) {
|
||||
@@ -145,7 +213,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
|
||||
return;
|
||||
}
|
||||
|
||||
// Count occurrences using fuzzy-normalized content for consistency
|
||||
// Count occurrences using fuzzy-normalized content for consistency with the matcher.
|
||||
const fuzzyContent = normalizeForFuzzyMatch(normalizedContent);
|
||||
const fuzzyOldText = normalizeForFuzzyMatch(normalizedOldText);
|
||||
const occurrences = fuzzyContent.split(fuzzyOldText).length - 1;
|
||||
@@ -162,20 +230,20 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if aborted before writing
|
||||
// Check if aborted before writing.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform replacement using the matched text position
|
||||
// When fuzzy matching was used, contentForReplacement is the normalized version
|
||||
// Perform replacement using the matched text position.
|
||||
// When fuzzy matching was used, contentForReplacement is the normalized version.
|
||||
const baseContent = matchResult.contentForReplacement;
|
||||
const newContent =
|
||||
baseContent.substring(0, matchResult.index) +
|
||||
normalizedNewText +
|
||||
baseContent.substring(matchResult.index + matchResult.matchLength);
|
||||
|
||||
// Verify the replacement actually changed something
|
||||
// Verify the replacement actually changed something.
|
||||
if (baseContent === newContent) {
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
@@ -191,12 +259,12 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
|
||||
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
||||
await ops.writeFile(absolutePath, finalContent);
|
||||
|
||||
// Check if aborted after writing
|
||||
// Check if aborted after writing.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up abort handler
|
||||
// Clean up abort handler.
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
@@ -212,7 +280,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
|
||||
details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine },
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Clean up abort handler
|
||||
// Clean up abort handler.
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
@@ -225,8 +293,43 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
|
||||
}),
|
||||
);
|
||||
},
|
||||
renderCall(args, theme, context) {
|
||||
const isSingleMode =
|
||||
typeof args?.path === "string" && typeof args?.oldText === "string" && typeof args?.newText === "string";
|
||||
if (context.argsComplete && isSingleMode) {
|
||||
const argsKey = JSON.stringify({ path: args.path, oldText: args.oldText, newText: args.newText });
|
||||
if (context.state.argsKey !== argsKey) {
|
||||
context.state.argsKey = argsKey;
|
||||
computeEditDiff(args.path!, args.oldText!, args.newText!, context.cwd).then((preview) => {
|
||||
if (context.state.argsKey === argsKey) {
|
||||
context.state.preview = preview;
|
||||
context.invalidate();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
text.setText(formatEditCall(args, context.state, theme));
|
||||
return text;
|
||||
},
|
||||
renderResult(result, _options, theme, context) {
|
||||
const output = formatEditResult(context.args, context.state, result as any, theme, context.isError);
|
||||
if (!output) {
|
||||
const component = (context.lastComponent as Container | undefined) ?? new Container();
|
||||
component.clear();
|
||||
return component;
|
||||
}
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
text.setText(output);
|
||||
return text;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Default edit tool using process.cwd() - for backwards compatibility */
|
||||
export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool<typeof editSchema> {
|
||||
return wrapToolDefinition(createEditToolDefinition(cwd, options));
|
||||
}
|
||||
|
||||
/** Default edit tool using process.cwd() for backwards compatibility. */
|
||||
export const editToolDefinition = createEditToolDefinition(process.cwd());
|
||||
export const editTool = createEditTool(process.cwd());
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
import { globSync } from "glob";
|
||||
import path from "path";
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
||||
import { ensureTool } from "../../utils/tools-manager.js";
|
||||
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
|
||||
import { resolveToCwd } from "./path-utils.js";
|
||||
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
|
||||
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
|
||||
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
|
||||
|
||||
function toPosixPath(value: string): string {
|
||||
@@ -31,41 +36,97 @@ export interface FindToolDetails {
|
||||
|
||||
/**
|
||||
* Pluggable operations for the find tool.
|
||||
* Override these to delegate file search to remote systems (e.g., SSH).
|
||||
* Override these to delegate file search to remote systems (for example SSH).
|
||||
*/
|
||||
export interface FindOperations {
|
||||
/** Check if path exists */
|
||||
exists: (absolutePath: string) => Promise<boolean> | boolean;
|
||||
/** Find files matching glob pattern. Returns relative paths. */
|
||||
/** Find files matching glob pattern. Returns relative or absolute paths. */
|
||||
glob: (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => Promise<string[]> | string[];
|
||||
}
|
||||
|
||||
const defaultFindOperations: FindOperations = {
|
||||
exists: existsSync,
|
||||
glob: (_pattern, _searchCwd, _options) => {
|
||||
// This is a placeholder - actual fd execution happens in execute
|
||||
return [];
|
||||
},
|
||||
// This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided.
|
||||
glob: () => [],
|
||||
};
|
||||
|
||||
export interface FindToolOptions {
|
||||
/** Custom operations for find. Default: local filesystem + fd */
|
||||
/** Custom operations for find. Default: local filesystem plus fd */
|
||||
operations?: FindOperations;
|
||||
}
|
||||
|
||||
export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool<typeof findSchema> {
|
||||
const customOps = options?.operations;
|
||||
function formatFindCall(
|
||||
args: { pattern: string; path?: string; limit?: number } | undefined,
|
||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
||||
): string {
|
||||
const pattern = str(args?.pattern);
|
||||
const rawPath = str(args?.path);
|
||||
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
|
||||
const limit = args?.limit;
|
||||
const invalidArg = invalidArgText(theme);
|
||||
let text =
|
||||
theme.fg("toolTitle", theme.bold("find")) +
|
||||
" " +
|
||||
(pattern === null ? invalidArg : theme.fg("accent", pattern || "")) +
|
||||
theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`);
|
||||
if (limit !== undefined) {
|
||||
text += theme.fg("toolOutput", ` (limit ${limit})`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function formatFindResult(
|
||||
result: {
|
||||
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||
details?: FindToolDetails;
|
||||
},
|
||||
options: ToolRenderResultOptions,
|
||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
||||
showImages: boolean,
|
||||
): string {
|
||||
const output = getTextOutput(result, showImages).trim();
|
||||
let text = "";
|
||||
if (output) {
|
||||
const lines = output.split("\n");
|
||||
const maxLines = options.expanded ? lines.length : 20;
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
const remaining = lines.length - maxLines;
|
||||
text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
|
||||
if (remaining > 0) {
|
||||
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
|
||||
}
|
||||
}
|
||||
|
||||
const resultLimit = result.details?.resultLimitReached;
|
||||
const truncation = result.details?.truncation;
|
||||
if (resultLimit || truncation?.truncated) {
|
||||
const warnings: string[] = [];
|
||||
if (resultLimit) warnings.push(`${resultLimit} results limit`);
|
||||
if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
|
||||
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function createFindToolDefinition(
|
||||
cwd: string,
|
||||
options?: FindToolOptions,
|
||||
): ToolDefinition<typeof findSchema, FindToolDetails | undefined> {
|
||||
const customOps = options?.operations;
|
||||
return {
|
||||
name: "find",
|
||||
label: "find",
|
||||
description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
|
||||
promptSnippet: "Find files by glob pattern (respects .gitignore)",
|
||||
parameters: findSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
async execute(
|
||||
_toolCallId,
|
||||
{ pattern, path: searchDir, limit }: { pattern: string; path?: string; limit?: number },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
_onUpdate?,
|
||||
_ctx?,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
@@ -81,20 +142,17 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
|
||||
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
||||
const ops = customOps ?? defaultFindOperations;
|
||||
|
||||
// If custom operations provided with glob, use that
|
||||
// If custom operations provide glob(), use that instead of fd.
|
||||
if (customOps?.glob) {
|
||||
if (!(await ops.exists(searchPath))) {
|
||||
reject(new Error(`Path not found: ${searchPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await ops.glob(pattern, searchPath, {
|
||||
ignore: ["**/node_modules/**", "**/.git/**"],
|
||||
limit: effectiveLimit,
|
||||
});
|
||||
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
if (results.length === 0) {
|
||||
resolve({
|
||||
content: [{ type: "text", text: "No files found matching pattern" }],
|
||||
@@ -103,36 +161,28 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
|
||||
return;
|
||||
}
|
||||
|
||||
// Relativize paths
|
||||
// Relativize paths against the search root for stable output.
|
||||
const relativized = results.map((p) => {
|
||||
if (p.startsWith(searchPath)) {
|
||||
return toPosixPath(p.slice(searchPath.length + 1));
|
||||
}
|
||||
if (p.startsWith(searchPath)) return toPosixPath(p.slice(searchPath.length + 1));
|
||||
return toPosixPath(path.relative(searchPath, p));
|
||||
});
|
||||
|
||||
const resultLimitReached = relativized.length >= effectiveLimit;
|
||||
const rawOutput = relativized.join("\n");
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
|
||||
let resultOutput = truncation.content;
|
||||
const details: FindToolDetails = {};
|
||||
const notices: string[] = [];
|
||||
|
||||
if (resultLimitReached) {
|
||||
notices.push(`${effectiveLimit} results limit reached`);
|
||||
details.resultLimitReached = effectiveLimit;
|
||||
}
|
||||
|
||||
if (truncation.truncated) {
|
||||
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
||||
details.truncation = truncation;
|
||||
}
|
||||
|
||||
if (notices.length > 0) {
|
||||
resultOutput += `\n\n[${notices.join(". ")}]`;
|
||||
}
|
||||
|
||||
resolve({
|
||||
content: [{ type: "text", text: resultOutput }],
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
@@ -140,14 +190,14 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: use fd
|
||||
// Default implementation uses fd.
|
||||
const fdPath = await ensureTool("fd", true);
|
||||
if (!fdPath) {
|
||||
reject(new Error("fd is not available and could not be downloaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build fd arguments
|
||||
// Build fd arguments.
|
||||
const args: string[] = [
|
||||
"--glob",
|
||||
"--color=never",
|
||||
@@ -155,14 +205,10 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
|
||||
"--max-results",
|
||||
String(effectiveLimit),
|
||||
];
|
||||
|
||||
// Include .gitignore files
|
||||
// Include .gitignore files from the search tree.
|
||||
const gitignoreFiles = new Set<string>();
|
||||
const rootGitignore = path.join(searchPath, ".gitignore");
|
||||
if (existsSync(rootGitignore)) {
|
||||
gitignoreFiles.add(rootGitignore);
|
||||
}
|
||||
|
||||
if (existsSync(rootGitignore)) gitignoreFiles.add(rootGitignore);
|
||||
try {
|
||||
const nestedGitignores = globSync("**/.gitignore", {
|
||||
cwd: searchPath,
|
||||
@@ -170,33 +216,21 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
|
||||
absolute: true,
|
||||
ignore: ["**/node_modules/**", "**/.git/**"],
|
||||
});
|
||||
for (const file of nestedGitignores) {
|
||||
gitignoreFiles.add(file);
|
||||
}
|
||||
for (const file of nestedGitignores) gitignoreFiles.add(file);
|
||||
} catch {
|
||||
// Ignore glob errors
|
||||
// ignore
|
||||
}
|
||||
|
||||
for (const gitignorePath of gitignoreFiles) {
|
||||
args.push("--ignore-file", gitignorePath);
|
||||
}
|
||||
|
||||
for (const gitignorePath of gitignoreFiles) args.push("--ignore-file", gitignorePath);
|
||||
args.push(pattern, searchPath);
|
||||
|
||||
const result = spawnSync(fdPath, args, {
|
||||
encoding: "utf-8",
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const result = spawnSync(fdPath, args, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
if (result.error) {
|
||||
reject(new Error(`Failed to run fd: ${result.error.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const output = result.stdout?.trim() || "";
|
||||
|
||||
if (result.status !== 0) {
|
||||
const errorMsg = result.stderr?.trim() || `fd exited with code ${result.status}`;
|
||||
if (!output) {
|
||||
@@ -204,7 +238,6 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!output) {
|
||||
resolve({
|
||||
content: [{ type: "text", text: "No files found matching pattern" }],
|
||||
@@ -215,11 +248,9 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
|
||||
|
||||
const lines = output.split("\n");
|
||||
const relativized: string[] = [];
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.replace(/\r$/, "").trim();
|
||||
if (!line) continue;
|
||||
|
||||
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
|
||||
let relativePath = line;
|
||||
if (line.startsWith(searchPath)) {
|
||||
@@ -227,38 +258,29 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
|
||||
} else {
|
||||
relativePath = path.relative(searchPath, line);
|
||||
}
|
||||
|
||||
if (hadTrailingSlash && !relativePath.endsWith("/")) {
|
||||
relativePath += "/";
|
||||
}
|
||||
|
||||
if (hadTrailingSlash && !relativePath.endsWith("/")) relativePath += "/";
|
||||
relativized.push(toPosixPath(relativePath));
|
||||
}
|
||||
|
||||
const resultLimitReached = relativized.length >= effectiveLimit;
|
||||
const rawOutput = relativized.join("\n");
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
|
||||
let resultOutput = truncation.content;
|
||||
const details: FindToolDetails = {};
|
||||
const notices: string[] = [];
|
||||
|
||||
if (resultLimitReached) {
|
||||
notices.push(
|
||||
`${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
|
||||
);
|
||||
details.resultLimitReached = effectiveLimit;
|
||||
}
|
||||
|
||||
if (truncation.truncated) {
|
||||
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
||||
details.truncation = truncation;
|
||||
}
|
||||
|
||||
if (notices.length > 0) {
|
||||
resultOutput += `\n\n[${notices.join(". ")}]`;
|
||||
}
|
||||
|
||||
resolve({
|
||||
content: [{ type: "text", text: resultOutput }],
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
@@ -270,8 +292,23 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
|
||||
})();
|
||||
});
|
||||
},
|
||||
renderCall(args, theme, context) {
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
text.setText(formatFindCall(args, theme));
|
||||
return text;
|
||||
},
|
||||
renderResult(result, options, theme, context) {
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
text.setText(formatFindResult(result as any, options, theme, context.showImages));
|
||||
return text;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Default find tool using process.cwd() - for backwards compatibility */
|
||||
export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool<typeof findSchema> {
|
||||
return wrapToolDefinition(createFindToolDefinition(cwd, options));
|
||||
}
|
||||
|
||||
/** Default find tool using process.cwd() for backwards compatibility. */
|
||||
export const findToolDefinition = createFindToolDefinition(process.cwd());
|
||||
export const findTool = createFindTool(process.cwd());
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { spawn } from "child_process";
|
||||
import { readFileSync, statSync } from "fs";
|
||||
import path from "path";
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
||||
import { ensureTool } from "../../utils/tools-manager.js";
|
||||
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
|
||||
import { resolveToCwd } from "./path-utils.js";
|
||||
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
|
||||
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
|
||||
import {
|
||||
DEFAULT_MAX_BYTES,
|
||||
formatSize,
|
||||
@@ -30,7 +35,6 @@ const grepSchema = Type.Object({
|
||||
});
|
||||
|
||||
export type GrepToolInput = Static<typeof grepSchema>;
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
export interface GrepToolDetails {
|
||||
@@ -41,10 +45,10 @@ export interface GrepToolDetails {
|
||||
|
||||
/**
|
||||
* Pluggable operations for the grep tool.
|
||||
* Override these to delegate search to remote systems (e.g., SSH).
|
||||
* Override these to delegate search to remote systems (for example SSH).
|
||||
*/
|
||||
export interface GrepOperations {
|
||||
/** Check if path is a directory. Throws if path doesn't exist. */
|
||||
/** Check if path is a directory. Throws if path does not exist. */
|
||||
isDirectory: (absolutePath: string) => Promise<boolean> | boolean;
|
||||
/** Read file contents for context lines */
|
||||
readFile: (absolutePath: string) => Promise<string> | string;
|
||||
@@ -56,20 +60,78 @@ const defaultGrepOperations: GrepOperations = {
|
||||
};
|
||||
|
||||
export interface GrepToolOptions {
|
||||
/** Custom operations for grep. Default: local filesystem + ripgrep */
|
||||
/** Custom operations for grep. Default: local filesystem plus ripgrep */
|
||||
operations?: GrepOperations;
|
||||
}
|
||||
|
||||
export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentTool<typeof grepSchema> {
|
||||
const customOps = options?.operations;
|
||||
function formatGrepCall(
|
||||
args: { pattern: string; path?: string; glob?: string; limit?: number } | undefined,
|
||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
||||
): string {
|
||||
const pattern = str(args?.pattern);
|
||||
const rawPath = str(args?.path);
|
||||
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
|
||||
const glob = str(args?.glob);
|
||||
const limit = args?.limit;
|
||||
const invalidArg = invalidArgText(theme);
|
||||
let text =
|
||||
theme.fg("toolTitle", theme.bold("grep")) +
|
||||
" " +
|
||||
(pattern === null ? invalidArg : theme.fg("accent", `/${pattern || ""}/`)) +
|
||||
theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`);
|
||||
if (glob) text += theme.fg("toolOutput", ` (${glob})`);
|
||||
if (limit !== undefined) text += theme.fg("toolOutput", ` limit ${limit}`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function formatGrepResult(
|
||||
result: {
|
||||
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||
details?: GrepToolDetails;
|
||||
},
|
||||
options: ToolRenderResultOptions,
|
||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
||||
showImages: boolean,
|
||||
): string {
|
||||
const output = getTextOutput(result, showImages).trim();
|
||||
let text = "";
|
||||
if (output) {
|
||||
const lines = output.split("\n");
|
||||
const maxLines = options.expanded ? lines.length : 15;
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
const remaining = lines.length - maxLines;
|
||||
text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
|
||||
if (remaining > 0) {
|
||||
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
|
||||
}
|
||||
}
|
||||
|
||||
const matchLimit = result.details?.matchLimitReached;
|
||||
const truncation = result.details?.truncation;
|
||||
const linesTruncated = result.details?.linesTruncated;
|
||||
if (matchLimit || truncation?.truncated || linesTruncated) {
|
||||
const warnings: string[] = [];
|
||||
if (matchLimit) warnings.push(`${matchLimit} matches limit`);
|
||||
if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
|
||||
if (linesTruncated) warnings.push("some lines truncated");
|
||||
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function createGrepToolDefinition(
|
||||
cwd: string,
|
||||
options?: GrepToolOptions,
|
||||
): ToolDefinition<typeof grepSchema, GrepToolDetails | undefined> {
|
||||
const customOps = options?.operations;
|
||||
return {
|
||||
name: "grep",
|
||||
label: "grep",
|
||||
description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`,
|
||||
promptSnippet: "Search file contents for patterns (respects .gitignore)",
|
||||
parameters: grepSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
async execute(
|
||||
_toolCallId,
|
||||
{
|
||||
pattern,
|
||||
path: searchDir,
|
||||
@@ -88,13 +150,14 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
|
||||
limit?: number;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
_onUpdate?,
|
||||
_ctx?,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const settle = (fn: () => void) => {
|
||||
if (!settled) {
|
||||
@@ -113,17 +176,16 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
|
||||
|
||||
const searchPath = resolveToCwd(searchDir || ".", cwd);
|
||||
const ops = customOps ?? defaultGrepOperations;
|
||||
|
||||
let isDirectory: boolean;
|
||||
try {
|
||||
isDirectory = await ops.isDirectory(searchPath);
|
||||
} catch (_err) {
|
||||
} catch {
|
||||
settle(() => reject(new Error(`Path not found: ${searchPath}`)));
|
||||
return;
|
||||
}
|
||||
|
||||
const contextValue = context && context > 0 ? context : 0;
|
||||
const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
|
||||
|
||||
const formatPath = (filePath: string): string => {
|
||||
if (isDirectory) {
|
||||
const relative = path.relative(searchPath, filePath);
|
||||
@@ -150,19 +212,9 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
|
||||
};
|
||||
|
||||
const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"];
|
||||
|
||||
if (ignoreCase) {
|
||||
args.push("--ignore-case");
|
||||
}
|
||||
|
||||
if (literal) {
|
||||
args.push("--fixed-strings");
|
||||
}
|
||||
|
||||
if (glob) {
|
||||
args.push("--glob", glob);
|
||||
}
|
||||
|
||||
if (ignoreCase) args.push("--ignore-case");
|
||||
if (literal) args.push("--fixed-strings");
|
||||
if (glob) args.push("--glob", glob);
|
||||
args.push(pattern, searchPath);
|
||||
|
||||
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
@@ -179,21 +231,17 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
|
||||
rl.close();
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
|
||||
const stopChild = (dueToLimit: boolean = false) => {
|
||||
const stopChild = (dueToLimit = false) => {
|
||||
if (!child.killed) {
|
||||
killedDueToLimit = dueToLimit;
|
||||
child.kill();
|
||||
}
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
stopChild();
|
||||
};
|
||||
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
@@ -201,59 +249,38 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
|
||||
const formatBlock = async (filePath: string, lineNumber: number): Promise<string[]> => {
|
||||
const relativePath = formatPath(filePath);
|
||||
const lines = await getFileLines(filePath);
|
||||
if (!lines.length) {
|
||||
return [`${relativePath}:${lineNumber}: (unable to read file)`];
|
||||
}
|
||||
|
||||
if (!lines.length) return [`${relativePath}:${lineNumber}: (unable to read file)`];
|
||||
const block: string[] = [];
|
||||
const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber;
|
||||
const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber;
|
||||
|
||||
for (let current = start; current <= end; current++) {
|
||||
const lineText = lines[current - 1] ?? "";
|
||||
const sanitized = lineText.replace(/\r/g, "");
|
||||
const isMatchLine = current === lineNumber;
|
||||
|
||||
// Truncate long lines
|
||||
// Truncate long lines so grep output stays compact.
|
||||
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
|
||||
if (wasTruncated) {
|
||||
linesTruncated = true;
|
||||
}
|
||||
|
||||
if (isMatchLine) {
|
||||
block.push(`${relativePath}:${current}: ${truncatedText}`);
|
||||
} else {
|
||||
block.push(`${relativePath}-${current}- ${truncatedText}`);
|
||||
}
|
||||
if (wasTruncated) linesTruncated = true;
|
||||
if (isMatchLine) block.push(`${relativePath}:${current}: ${truncatedText}`);
|
||||
else block.push(`${relativePath}-${current}- ${truncatedText}`);
|
||||
}
|
||||
|
||||
return block;
|
||||
};
|
||||
|
||||
// Collect matches during streaming, format after
|
||||
// Collect matches during streaming, then format them after rg exits.
|
||||
const matches: Array<{ filePath: string; lineNumber: number }> = [];
|
||||
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim() || matchCount >= effectiveLimit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!line.trim() || matchCount >= effectiveLimit) return;
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "match") {
|
||||
matchCount++;
|
||||
const filePath = event.data?.path?.text;
|
||||
const lineNumber = event.data?.line_number;
|
||||
|
||||
if (filePath && typeof lineNumber === "number") {
|
||||
matches.push({ filePath, lineNumber });
|
||||
}
|
||||
|
||||
if (filePath && typeof lineNumber === "number") matches.push({ filePath, lineNumber });
|
||||
if (matchCount >= effectiveLimit) {
|
||||
matchLimitReached = true;
|
||||
stopChild(true);
|
||||
@@ -265,21 +292,17 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
|
||||
cleanup();
|
||||
settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`)));
|
||||
});
|
||||
|
||||
child.on("close", async (code) => {
|
||||
cleanup();
|
||||
|
||||
if (aborted) {
|
||||
settle(() => reject(new Error("Operation aborted")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!killedDueToLimit && code !== 0 && code !== 1) {
|
||||
const errorMsg = stderr.trim() || `ripgrep exited with code ${code}`;
|
||||
settle(() => reject(new Error(errorMsg)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchCount === 0) {
|
||||
settle(() =>
|
||||
resolve({ content: [{ type: "text", text: "No matches found" }], details: undefined }),
|
||||
@@ -287,45 +310,36 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
|
||||
return;
|
||||
}
|
||||
|
||||
// Format matches (async to support remote file reading)
|
||||
// Format matches after streaming finishes so custom readFile() backends can be async.
|
||||
for (const match of matches) {
|
||||
const block = await formatBlock(match.filePath, match.lineNumber);
|
||||
outputLines.push(...block);
|
||||
}
|
||||
|
||||
// Apply byte truncation (no line limit since we already have match limit)
|
||||
const rawOutput = outputLines.join("\n");
|
||||
// Apply byte truncation. There is no line limit here because the match limit already capped rows.
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
|
||||
let output = truncation.content;
|
||||
const details: GrepToolDetails = {};
|
||||
|
||||
// Build notices
|
||||
// Build actionable notices for truncation and match limits.
|
||||
const notices: string[] = [];
|
||||
|
||||
if (matchLimitReached) {
|
||||
notices.push(
|
||||
`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
|
||||
);
|
||||
details.matchLimitReached = effectiveLimit;
|
||||
}
|
||||
|
||||
if (truncation.truncated) {
|
||||
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
||||
details.truncation = truncation;
|
||||
}
|
||||
|
||||
if (linesTruncated) {
|
||||
notices.push(
|
||||
`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`,
|
||||
);
|
||||
details.linesTruncated = true;
|
||||
}
|
||||
|
||||
if (notices.length > 0) {
|
||||
output += `\n\n[${notices.join(". ")}]`;
|
||||
}
|
||||
|
||||
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
||||
settle(() =>
|
||||
resolve({
|
||||
content: [{ type: "text", text: output }],
|
||||
@@ -339,8 +353,23 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
|
||||
})();
|
||||
});
|
||||
},
|
||||
renderCall(args, theme, context) {
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
text.setText(formatGrepCall(args, theme));
|
||||
return text;
|
||||
},
|
||||
renderResult(result, options, theme, context) {
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
text.setText(formatGrepResult(result as any, options, theme, context.showImages));
|
||||
return text;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Default grep tool using process.cwd() - for backwards compatibility */
|
||||
export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentTool<typeof grepSchema> {
|
||||
return wrapToolDefinition(createGrepToolDefinition(cwd, options));
|
||||
}
|
||||
|
||||
/** Default grep tool using process.cwd() for backwards compatibility. */
|
||||
export const grepToolDefinition = createGrepToolDefinition(process.cwd());
|
||||
export const grepTool = createGrepTool(process.cwd());
|
||||
|
||||
@@ -6,49 +6,61 @@ export {
|
||||
type BashToolInput,
|
||||
type BashToolOptions,
|
||||
bashTool,
|
||||
bashToolDefinition,
|
||||
createBashTool,
|
||||
createBashToolDefinition,
|
||||
createLocalBashOperations,
|
||||
} from "./bash.js";
|
||||
export {
|
||||
createEditTool,
|
||||
createEditToolDefinition,
|
||||
type EditOperations,
|
||||
type EditToolDetails,
|
||||
type EditToolInput,
|
||||
type EditToolOptions,
|
||||
editTool,
|
||||
editToolDefinition,
|
||||
} from "./edit.js";
|
||||
export { withFileMutationQueue } from "./file-mutation-queue.js";
|
||||
export {
|
||||
createFindTool,
|
||||
createFindToolDefinition,
|
||||
type FindOperations,
|
||||
type FindToolDetails,
|
||||
type FindToolInput,
|
||||
type FindToolOptions,
|
||||
findTool,
|
||||
findToolDefinition,
|
||||
} from "./find.js";
|
||||
export {
|
||||
createGrepTool,
|
||||
createGrepToolDefinition,
|
||||
type GrepOperations,
|
||||
type GrepToolDetails,
|
||||
type GrepToolInput,
|
||||
type GrepToolOptions,
|
||||
grepTool,
|
||||
grepToolDefinition,
|
||||
} from "./grep.js";
|
||||
export {
|
||||
createLsTool,
|
||||
createLsToolDefinition,
|
||||
type LsOperations,
|
||||
type LsToolDetails,
|
||||
type LsToolInput,
|
||||
type LsToolOptions,
|
||||
lsTool,
|
||||
lsToolDefinition,
|
||||
} from "./ls.js";
|
||||
export {
|
||||
createReadTool,
|
||||
createReadToolDefinition,
|
||||
type ReadOperations,
|
||||
type ReadToolDetails,
|
||||
type ReadToolInput,
|
||||
type ReadToolOptions,
|
||||
readTool,
|
||||
readToolDefinition,
|
||||
} from "./read.js";
|
||||
export {
|
||||
DEFAULT_MAX_BYTES,
|
||||
@@ -62,31 +74,42 @@ export {
|
||||
} from "./truncate.js";
|
||||
export {
|
||||
createWriteTool,
|
||||
createWriteToolDefinition,
|
||||
type WriteOperations,
|
||||
type WriteToolInput,
|
||||
type WriteToolOptions,
|
||||
writeTool,
|
||||
writeToolDefinition,
|
||||
} from "./write.js";
|
||||
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { type BashToolOptions, bashTool, createBashTool } from "./bash.js";
|
||||
import { createEditTool, editTool } from "./edit.js";
|
||||
import { createFindTool, findTool } from "./find.js";
|
||||
import { createGrepTool, grepTool } from "./grep.js";
|
||||
import { createLsTool, lsTool } from "./ls.js";
|
||||
import { createReadTool, type ReadToolOptions, readTool } from "./read.js";
|
||||
import { createWriteTool, writeTool } from "./write.js";
|
||||
import type { ToolDefinition } from "../extensions/types.js";
|
||||
import {
|
||||
type BashToolOptions,
|
||||
bashTool,
|
||||
bashToolDefinition,
|
||||
createBashTool,
|
||||
createBashToolDefinition,
|
||||
} from "./bash.js";
|
||||
import { createEditTool, createEditToolDefinition, editTool, editToolDefinition } from "./edit.js";
|
||||
import { createFindTool, createFindToolDefinition, findTool, findToolDefinition } from "./find.js";
|
||||
import { createGrepTool, createGrepToolDefinition, grepTool, grepToolDefinition } from "./grep.js";
|
||||
import { createLsTool, createLsToolDefinition, lsTool, lsToolDefinition } from "./ls.js";
|
||||
import {
|
||||
createReadTool,
|
||||
createReadToolDefinition,
|
||||
type ReadToolOptions,
|
||||
readTool,
|
||||
readToolDefinition,
|
||||
} from "./read.js";
|
||||
import { createWriteTool, createWriteToolDefinition, writeTool, writeToolDefinition } from "./write.js";
|
||||
|
||||
/** Tool type (AgentTool from pi-ai) */
|
||||
export type Tool = AgentTool<any>;
|
||||
export type ToolDef = ToolDefinition<any, any>;
|
||||
|
||||
// Default tools for full access mode (using process.cwd())
|
||||
export const codingTools: Tool[] = [readTool, bashTool, editTool, writeTool];
|
||||
|
||||
// Read-only tools for exploration without modification (using process.cwd())
|
||||
export const readOnlyTools: Tool[] = [readTool, grepTool, findTool, lsTool];
|
||||
|
||||
// All available tools (using process.cwd())
|
||||
export const allTools = {
|
||||
read: readTool,
|
||||
bash: bashTool,
|
||||
@@ -97,18 +120,53 @@ export const allTools = {
|
||||
ls: lsTool,
|
||||
};
|
||||
|
||||
export const allToolDefinitions = {
|
||||
read: readToolDefinition,
|
||||
bash: bashToolDefinition,
|
||||
edit: editToolDefinition,
|
||||
write: writeToolDefinition,
|
||||
grep: grepToolDefinition,
|
||||
find: findToolDefinition,
|
||||
ls: lsToolDefinition,
|
||||
};
|
||||
|
||||
export type ToolName = keyof typeof allTools;
|
||||
|
||||
export interface ToolsOptions {
|
||||
/** Options for the read tool */
|
||||
read?: ReadToolOptions;
|
||||
/** Options for the bash tool */
|
||||
bash?: BashToolOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create coding tools configured for a specific working directory.
|
||||
*/
|
||||
export function createCodingToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] {
|
||||
return [
|
||||
createReadToolDefinition(cwd, options?.read),
|
||||
createBashToolDefinition(cwd, options?.bash),
|
||||
createEditToolDefinition(cwd),
|
||||
createWriteToolDefinition(cwd),
|
||||
];
|
||||
}
|
||||
|
||||
export function createReadOnlyToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] {
|
||||
return [
|
||||
createReadToolDefinition(cwd, options?.read),
|
||||
createGrepToolDefinition(cwd),
|
||||
createFindToolDefinition(cwd),
|
||||
createLsToolDefinition(cwd),
|
||||
];
|
||||
}
|
||||
|
||||
export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): Record<ToolName, ToolDef> {
|
||||
return {
|
||||
read: createReadToolDefinition(cwd, options?.read),
|
||||
bash: createBashToolDefinition(cwd, options?.bash),
|
||||
edit: createEditToolDefinition(cwd),
|
||||
write: createWriteToolDefinition(cwd),
|
||||
grep: createGrepToolDefinition(cwd),
|
||||
find: createFindToolDefinition(cwd),
|
||||
ls: createLsToolDefinition(cwd),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCodingTools(cwd: string, options?: ToolsOptions): Tool[] {
|
||||
return [
|
||||
createReadTool(cwd, options?.read),
|
||||
@@ -118,16 +176,10 @@ export function createCodingTools(cwd: string, options?: ToolsOptions): Tool[] {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create read-only tools configured for a specific working directory.
|
||||
*/
|
||||
export function createReadOnlyTools(cwd: string, options?: ToolsOptions): Tool[] {
|
||||
return [createReadTool(cwd, options?.read), createGrepTool(cwd), createFindTool(cwd), createLsTool(cwd)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create all tools configured for a specific working directory.
|
||||
*/
|
||||
export function createAllTools(cwd: string, options?: ToolsOptions): Record<ToolName, Tool> {
|
||||
return {
|
||||
read: createReadTool(cwd, options?.read),
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { existsSync, readdirSync, statSync } from "fs";
|
||||
import nodePath from "path";
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
||||
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
|
||||
import { resolveToCwd } from "./path-utils.js";
|
||||
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
|
||||
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
|
||||
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
|
||||
|
||||
const lsSchema = Type.Object({
|
||||
@@ -21,12 +26,12 @@ export interface LsToolDetails {
|
||||
|
||||
/**
|
||||
* Pluggable operations for the ls tool.
|
||||
* Override these to delegate directory listing to remote systems (e.g., SSH).
|
||||
* Override these to delegate directory listing to remote systems (for example SSH).
|
||||
*/
|
||||
export interface LsOperations {
|
||||
/** Check if path exists */
|
||||
exists: (absolutePath: string) => Promise<boolean> | boolean;
|
||||
/** Get file/directory stats. Throws if not found. */
|
||||
/** Get file or directory stats. Throws if not found. */
|
||||
stat: (absolutePath: string) => Promise<{ isDirectory: () => boolean }> | { isDirectory: () => boolean };
|
||||
/** Read directory entries */
|
||||
readdir: (absolutePath: string) => Promise<string[]> | string[];
|
||||
@@ -43,19 +48,72 @@ export interface LsToolOptions {
|
||||
operations?: LsOperations;
|
||||
}
|
||||
|
||||
export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<typeof lsSchema> {
|
||||
const ops = options?.operations ?? defaultLsOperations;
|
||||
function formatLsCall(
|
||||
args: { path?: string; limit?: number } | undefined,
|
||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
||||
): string {
|
||||
const rawPath = str(args?.path);
|
||||
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
|
||||
const limit = args?.limit;
|
||||
const invalidArg = invalidArgText(theme);
|
||||
let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${path === null ? invalidArg : theme.fg("accent", path)}`;
|
||||
if (limit !== undefined) {
|
||||
text += theme.fg("toolOutput", ` (limit ${limit})`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function formatLsResult(
|
||||
result: {
|
||||
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||
details?: LsToolDetails;
|
||||
},
|
||||
options: ToolRenderResultOptions,
|
||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
||||
showImages: boolean,
|
||||
): string {
|
||||
const output = getTextOutput(result, showImages).trim();
|
||||
let text = "";
|
||||
if (output) {
|
||||
const lines = output.split("\n");
|
||||
const maxLines = options.expanded ? lines.length : 20;
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
const remaining = lines.length - maxLines;
|
||||
text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
|
||||
if (remaining > 0) {
|
||||
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
|
||||
}
|
||||
}
|
||||
|
||||
const entryLimit = result.details?.entryLimitReached;
|
||||
const truncation = result.details?.truncation;
|
||||
if (entryLimit || truncation?.truncated) {
|
||||
const warnings: string[] = [];
|
||||
if (entryLimit) warnings.push(`${entryLimit} entries limit`);
|
||||
if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
|
||||
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function createLsToolDefinition(
|
||||
cwd: string,
|
||||
options?: LsToolOptions,
|
||||
): ToolDefinition<typeof lsSchema, LsToolDetails | undefined> {
|
||||
const ops = options?.operations ?? defaultLsOperations;
|
||||
return {
|
||||
name: "ls",
|
||||
label: "ls",
|
||||
description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
|
||||
promptSnippet: "List directory contents",
|
||||
parameters: lsSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
async execute(
|
||||
_toolCallId,
|
||||
{ path, limit }: { path?: string; limit?: number },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
_onUpdate?,
|
||||
_ctx?,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
@@ -70,20 +128,20 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<ty
|
||||
const dirPath = resolveToCwd(path || ".", cwd);
|
||||
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
||||
|
||||
// Check if path exists
|
||||
// Check if path exists.
|
||||
if (!(await ops.exists(dirPath))) {
|
||||
reject(new Error(`Path not found: ${dirPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if path is a directory
|
||||
// Check if path is a directory.
|
||||
const stat = await ops.stat(dirPath);
|
||||
if (!stat.isDirectory()) {
|
||||
reject(new Error(`Not a directory: ${dirPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Read directory entries
|
||||
// Read directory entries.
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await ops.readdir(dirPath);
|
||||
@@ -92,13 +150,12 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<ty
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort alphabetically (case-insensitive)
|
||||
// Sort alphabetically, case-insensitive.
|
||||
entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
|
||||
// Format entries with directory indicators
|
||||
// Format entries with directory indicators.
|
||||
const results: string[] = [];
|
||||
let entryLimitReached = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (results.length >= effectiveLimit) {
|
||||
entryLimitReached = true;
|
||||
@@ -107,17 +164,13 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<ty
|
||||
|
||||
const fullPath = nodePath.join(dirPath, entry);
|
||||
let suffix = "";
|
||||
|
||||
try {
|
||||
const entryStat = await ops.stat(fullPath);
|
||||
if (entryStat.isDirectory()) {
|
||||
suffix = "/";
|
||||
}
|
||||
if (entryStat.isDirectory()) suffix = "/";
|
||||
} catch {
|
||||
// Skip entries we can't stat
|
||||
// Skip entries we cannot stat.
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(entry + suffix);
|
||||
}
|
||||
|
||||
@@ -128,26 +181,21 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<ty
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply byte truncation (no line limit since we already have entry limit)
|
||||
const rawOutput = results.join("\n");
|
||||
// Apply byte truncation. There is no separate line limit because entry count is already capped.
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
|
||||
let output = truncation.content;
|
||||
const details: LsToolDetails = {};
|
||||
|
||||
// Build notices
|
||||
// Build actionable notices for truncation and entry limits.
|
||||
const notices: string[] = [];
|
||||
|
||||
if (entryLimitReached) {
|
||||
notices.push(`${effectiveLimit} entries limit reached. Use limit=${effectiveLimit * 2} for more`);
|
||||
details.entryLimitReached = effectiveLimit;
|
||||
}
|
||||
|
||||
if (truncation.truncated) {
|
||||
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
||||
details.truncation = truncation;
|
||||
}
|
||||
|
||||
if (notices.length > 0) {
|
||||
output += `\n\n[${notices.join(". ")}]`;
|
||||
}
|
||||
@@ -163,8 +211,23 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<ty
|
||||
})();
|
||||
});
|
||||
},
|
||||
renderCall(args, theme, context) {
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
text.setText(formatLsCall(args, theme));
|
||||
return text;
|
||||
},
|
||||
renderResult(result, options, theme, context) {
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
text.setText(formatLsResult(result as any, options, theme, context.showImages));
|
||||
return text;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Default ls tool using process.cwd() - for backwards compatibility */
|
||||
export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<typeof lsSchema> {
|
||||
return wrapToolDefinition(createLsToolDefinition(cwd, options));
|
||||
}
|
||||
|
||||
/** Default ls tool using process.cwd() for backwards compatibility. */
|
||||
export const lsToolDefinition = createLsToolDefinition(process.cwd());
|
||||
export const lsTool = createLsTool(process.cwd());
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { constants } from "fs";
|
||||
import { access as fsAccess, readFile as fsReadFile } from "fs/promises";
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
||||
import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js";
|
||||
import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js";
|
||||
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
|
||||
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
|
||||
import { resolveReadPath } from "./path-utils.js";
|
||||
import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.js";
|
||||
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
|
||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
|
||||
|
||||
const readSchema = Type.Object({
|
||||
@@ -22,14 +28,14 @@ export interface ReadToolDetails {
|
||||
|
||||
/**
|
||||
* Pluggable operations for the read tool.
|
||||
* Override these to delegate file reading to remote systems (e.g., SSH).
|
||||
* Override these to delegate file reading to remote systems (for example SSH).
|
||||
*/
|
||||
export interface ReadOperations {
|
||||
/** Read file contents as a Buffer */
|
||||
readFile: (absolutePath: string) => Promise<Buffer>;
|
||||
/** Check if file is readable (throw if not) */
|
||||
access: (absolutePath: string) => Promise<void>;
|
||||
/** Detect image MIME type, return null/undefined for non-images */
|
||||
/** Detect image MIME type, return null or undefined for non-images */
|
||||
detectImageMimeType?: (absolutePath: string) => Promise<string | null | undefined>;
|
||||
}
|
||||
|
||||
@@ -46,104 +52,143 @@ export interface ReadToolOptions {
|
||||
operations?: ReadOperations;
|
||||
}
|
||||
|
||||
export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
|
||||
function formatReadCall(
|
||||
args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined,
|
||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
||||
): string {
|
||||
const rawPath = str(args?.file_path ?? args?.path);
|
||||
const path = rawPath !== null ? shortenPath(rawPath) : null;
|
||||
const offset = args?.offset;
|
||||
const limit = args?.limit;
|
||||
const invalidArg = invalidArgText(theme);
|
||||
let pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
|
||||
if (offset !== undefined || limit !== undefined) {
|
||||
const startLine = offset ?? 1;
|
||||
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
||||
pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
||||
}
|
||||
return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`;
|
||||
}
|
||||
|
||||
function trimTrailingEmptyLines(lines: string[]): string[] {
|
||||
let end = lines.length;
|
||||
while (end > 0 && lines[end - 1] === "") {
|
||||
end--;
|
||||
}
|
||||
return lines.slice(0, end);
|
||||
}
|
||||
|
||||
function formatReadResult(
|
||||
args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined,
|
||||
result: { content: (TextContent | ImageContent)[]; details?: ReadToolDetails },
|
||||
options: ToolRenderResultOptions,
|
||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
||||
showImages: boolean,
|
||||
): string {
|
||||
const rawPath = str(args?.file_path ?? args?.path);
|
||||
const output = getTextOutput(result as any, showImages);
|
||||
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
|
||||
const renderedLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
|
||||
const lines = trimTrailingEmptyLines(renderedLines);
|
||||
const maxLines = options.expanded ? lines.length : 10;
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
const remaining = lines.length - maxLines;
|
||||
let text = `\n${displayLines.map((line) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`;
|
||||
if (remaining > 0) {
|
||||
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
|
||||
}
|
||||
|
||||
const truncation = result.details?.truncation;
|
||||
if (truncation?.truncated) {
|
||||
if (truncation.firstLineExceedsLimit) {
|
||||
text += `\n${theme.fg("warning", `[First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit]`)}`;
|
||||
} else if (truncation.truncatedBy === "lines") {
|
||||
text += `\n${theme.fg("warning", `[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)]`)}`;
|
||||
} else {
|
||||
text += `\n${theme.fg("warning", `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`)}`;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function createReadToolDefinition(
|
||||
cwd: string,
|
||||
options?: ReadToolOptions,
|
||||
): ToolDefinition<typeof readSchema, ReadToolDetails | undefined> {
|
||||
const autoResizeImages = options?.autoResizeImages ?? true;
|
||||
const ops = options?.operations ?? defaultReadOperations;
|
||||
|
||||
return {
|
||||
name: "read",
|
||||
label: "read",
|
||||
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
|
||||
promptSnippet: "Read file contents",
|
||||
promptGuidelines: ["Use read to examine files instead of cat or sed."],
|
||||
parameters: readSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
async execute(
|
||||
_toolCallId,
|
||||
{ path, offset, limit }: { path: string; offset?: number; limit?: number },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
_onUpdate?,
|
||||
_ctx?,
|
||||
) {
|
||||
const absolutePath = resolveReadPath(path, cwd);
|
||||
|
||||
return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>(
|
||||
(resolve, reject) => {
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
let aborted = false;
|
||||
|
||||
// Set up abort handler
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new Error("Operation aborted"));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
// Perform the read operation
|
||||
(async () => {
|
||||
try {
|
||||
// Check if file exists
|
||||
// Check if file exists and is readable.
|
||||
await ops.access(absolutePath);
|
||||
|
||||
// Check if aborted before reading
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (aborted) return;
|
||||
const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined;
|
||||
|
||||
// Read the file based on type
|
||||
let content: (TextContent | ImageContent)[];
|
||||
let details: ReadToolDetails | undefined;
|
||||
|
||||
if (mimeType) {
|
||||
// Read as image (binary)
|
||||
// Read image as binary.
|
||||
const buffer = await ops.readFile(absolutePath);
|
||||
const base64 = buffer.toString("base64");
|
||||
|
||||
if (autoResizeImages) {
|
||||
// Resize image if needed
|
||||
// Resize image if needed before sending it back to the model.
|
||||
const resized = await resizeImage({ type: "image", data: base64, mimeType });
|
||||
const dimensionNote = formatDimensionNote(resized);
|
||||
|
||||
let textNote = `Read image file [${resized.mimeType}]`;
|
||||
if (dimensionNote) {
|
||||
textNote += `\n${dimensionNote}`;
|
||||
}
|
||||
|
||||
if (dimensionNote) textNote += `\n${dimensionNote}`;
|
||||
content = [
|
||||
{ type: "text", text: textNote },
|
||||
{ type: "image", data: resized.data, mimeType: resized.mimeType },
|
||||
];
|
||||
} else {
|
||||
const textNote = `Read image file [${mimeType}]`;
|
||||
content = [
|
||||
{ type: "text", text: textNote },
|
||||
{ type: "text", text: `Read image file [${mimeType}]` },
|
||||
{ type: "image", data: base64, mimeType },
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// Read as text
|
||||
// Read text content.
|
||||
const buffer = await ops.readFile(absolutePath);
|
||||
const textContent = buffer.toString("utf-8");
|
||||
const allLines = textContent.split("\n");
|
||||
const totalFileLines = allLines.length;
|
||||
|
||||
// Apply offset if specified (1-indexed to 0-indexed)
|
||||
// Apply offset if specified. Convert from 1-indexed input to 0-indexed array access.
|
||||
const startLine = offset ? Math.max(0, offset - 1) : 0;
|
||||
const startLineDisplay = startLine + 1; // For display (1-indexed)
|
||||
|
||||
// Check if offset is out of bounds
|
||||
const startLineDisplay = startLine + 1;
|
||||
// Check if offset is out of bounds.
|
||||
if (startLine >= allLines.length) {
|
||||
throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
|
||||
}
|
||||
|
||||
// If limit is specified by user, use it; otherwise we'll let truncateHead decide
|
||||
let selectedContent: string;
|
||||
let userLimitedLines: number | undefined;
|
||||
// If limit is specified by the user, honor it first. Otherwise truncateHead decides.
|
||||
if (limit !== undefined) {
|
||||
const endLine = Math.min(startLine + limit, allLines.length);
|
||||
selectedContent = allLines.slice(startLine, endLine).join("\n");
|
||||
@@ -151,24 +196,19 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
|
||||
} else {
|
||||
selectedContent = allLines.slice(startLine).join("\n");
|
||||
}
|
||||
|
||||
// Apply truncation (respects both line and byte limits)
|
||||
// Apply truncation, respecting both line and byte limits.
|
||||
const truncation = truncateHead(selectedContent);
|
||||
|
||||
let outputText: string;
|
||||
|
||||
if (truncation.firstLineExceedsLimit) {
|
||||
// First line at offset exceeds 30KB - tell model to use bash
|
||||
// First line alone exceeds the byte limit. Point the model at a bash fallback.
|
||||
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
|
||||
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
|
||||
details = { truncation };
|
||||
} else if (truncation.truncated) {
|
||||
// Truncation occurred - build actionable notice
|
||||
// Truncation occurred. Build an actionable continuation notice.
|
||||
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
|
||||
const nextOffset = endLineDisplay + 1;
|
||||
|
||||
outputText = truncation.content;
|
||||
|
||||
if (truncation.truncatedBy === "lines") {
|
||||
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
|
||||
} else {
|
||||
@@ -176,47 +216,45 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
|
||||
}
|
||||
details = { truncation };
|
||||
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
|
||||
// User specified limit, there's more content, but no truncation
|
||||
// User-specified limit stopped early, but the file still has more content.
|
||||
const remaining = allLines.length - (startLine + userLimitedLines);
|
||||
const nextOffset = startLine + userLimitedLines + 1;
|
||||
|
||||
outputText = truncation.content;
|
||||
outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
|
||||
outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
|
||||
} else {
|
||||
// No truncation, no user limit exceeded
|
||||
// No truncation and no remaining user-limited content.
|
||||
outputText = truncation.content;
|
||||
}
|
||||
|
||||
content = [{ type: "text", text: outputText }];
|
||||
}
|
||||
|
||||
// Check if aborted after reading
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (aborted) return;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve({ content, details });
|
||||
} catch (error: any) {
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (!aborted) {
|
||||
reject(error);
|
||||
}
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (!aborted) reject(error);
|
||||
}
|
||||
})();
|
||||
},
|
||||
);
|
||||
},
|
||||
renderCall(args, theme, context) {
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
text.setText(formatReadCall(args, theme));
|
||||
return text;
|
||||
},
|
||||
renderResult(result, options, theme, context) {
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
text.setText(formatReadResult(context.args, result as any, options, theme, context.showImages));
|
||||
return text;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Default read tool using process.cwd() - for backwards compatibility */
|
||||
export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
|
||||
return wrapToolDefinition(createReadToolDefinition(cwd, options));
|
||||
}
|
||||
|
||||
/** Default read tool using process.cwd() for backwards compatibility. */
|
||||
export const readToolDefinition = createReadToolDefinition(process.cwd());
|
||||
export const readTool = createReadTool(process.cwd());
|
||||
|
||||
64
packages/coding-agent/src/core/tools/render-utils.ts
Normal file
64
packages/coding-agent/src/core/tools/render-utils.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as os from "node:os";
|
||||
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
|
||||
import { getCapabilities, getImageDimensions, imageFallback } from "@mariozechner/pi-tui";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { sanitizeBinaryOutput } from "../../utils/shell.js";
|
||||
|
||||
export function shortenPath(path: unknown): string {
|
||||
if (typeof path !== "string") return "";
|
||||
const home = os.homedir();
|
||||
if (path.startsWith(home)) {
|
||||
return `~${path.slice(home.length)}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function str(value: unknown): string | null {
|
||||
if (typeof value === "string") return value;
|
||||
if (value == null) return "";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function replaceTabs(text: string): string {
|
||||
return text.replace(/\t/g, " ");
|
||||
}
|
||||
|
||||
export function normalizeDisplayText(text: string): string {
|
||||
return text.replace(/\r/g, "");
|
||||
}
|
||||
|
||||
export function getTextOutput(
|
||||
result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> } | undefined,
|
||||
showImages: boolean,
|
||||
): string {
|
||||
if (!result) return "";
|
||||
|
||||
const textBlocks = result.content.filter((c) => c.type === "text");
|
||||
const imageBlocks = result.content.filter((c) => c.type === "image");
|
||||
|
||||
let output = textBlocks.map((c) => sanitizeBinaryOutput(stripAnsi(c.text || "")).replace(/\r/g, "")).join("\n");
|
||||
|
||||
const caps = getCapabilities();
|
||||
if (imageBlocks.length > 0 && (!caps.images || !showImages)) {
|
||||
const imageIndicators = imageBlocks
|
||||
.map((img) => {
|
||||
const mimeType = img.mimeType ?? "image/unknown";
|
||||
const dims =
|
||||
img.data && img.mimeType ? (getImageDimensions(img.data, img.mimeType) ?? undefined) : undefined;
|
||||
return imageFallback(mimeType, dims);
|
||||
})
|
||||
.join("\n");
|
||||
output = output ? `${output}\n${imageIndicators}` : imageIndicators;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export type ToolRenderResultLike<TDetails> = {
|
||||
content: (TextContent | ImageContent)[];
|
||||
details: TDetails;
|
||||
};
|
||||
|
||||
export function invalidArgText(theme: { fg: (name: any, text: string) => string }): string {
|
||||
return theme.fg("error", "[invalid arg]");
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { ExtensionContext, ToolDefinition } from "../extensions/types.js";
|
||||
|
||||
/** Wrap a ToolDefinition into an AgentTool for the core runtime. */
|
||||
export function wrapToolDefinition<TDetails = unknown>(
|
||||
definition: ToolDefinition<any, TDetails>,
|
||||
ctxFactory?: () => ExtensionContext,
|
||||
): AgentTool<any, TDetails> {
|
||||
return {
|
||||
name: definition.name,
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
parameters: definition.parameters,
|
||||
execute: (toolCallId, params, signal, onUpdate) =>
|
||||
definition.execute(toolCallId, params, signal, onUpdate, ctxFactory?.() as ExtensionContext),
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrap multiple ToolDefinitions into AgentTools for the core runtime. */
|
||||
export function wrapToolDefinitions(
|
||||
definitions: ToolDefinition<any, any>[],
|
||||
ctxFactory?: () => ExtensionContext,
|
||||
): AgentTool<any>[] {
|
||||
return definitions.map((definition) => wrapToolDefinition(definition, ctxFactory));
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize a minimal ToolDefinition from an AgentTool.
|
||||
*
|
||||
* This keeps AgentSession's internal registry definition-first even when a caller
|
||||
* provides plain AgentTool overrides that do not include prompt metadata or renderers.
|
||||
*/
|
||||
export function createToolDefinitionFromAgentTool(tool: AgentTool<any>): ToolDefinition<any, unknown> {
|
||||
return {
|
||||
name: tool.name,
|
||||
label: tool.label,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters as any,
|
||||
execute: async (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Container, Text } from "@mariozechner/pi-tui";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises";
|
||||
import { dirname } from "path";
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
||||
import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js";
|
||||
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
|
||||
import { withFileMutationQueue } from "./file-mutation-queue.js";
|
||||
import { resolveToCwd } from "./path-utils.js";
|
||||
import { invalidArgText, normalizeDisplayText, replaceTabs, shortenPath, str } from "./render-utils.js";
|
||||
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
|
||||
|
||||
const writeSchema = Type.Object({
|
||||
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
|
||||
@@ -14,12 +20,12 @@ export type WriteToolInput = Static<typeof writeSchema>;
|
||||
|
||||
/**
|
||||
* Pluggable operations for the write tool.
|
||||
* Override these to delegate file writing to remote systems (e.g., SSH).
|
||||
* Override these to delegate file writing to remote systems (for example SSH).
|
||||
*/
|
||||
export interface WriteOperations {
|
||||
/** Write content to a file */
|
||||
writeFile: (absolutePath: string, content: string) => Promise<void>;
|
||||
/** Create directory (recursively) */
|
||||
/** Create directory recursively */
|
||||
mkdir: (dir: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -33,70 +39,191 @@ export interface WriteToolOptions {
|
||||
operations?: WriteOperations;
|
||||
}
|
||||
|
||||
export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool<typeof writeSchema> {
|
||||
const ops = options?.operations ?? defaultWriteOperations;
|
||||
type WriteHighlightCache = {
|
||||
rawPath: string | null;
|
||||
lang: string;
|
||||
rawContent: string;
|
||||
normalizedLines: string[];
|
||||
highlightedLines: string[];
|
||||
};
|
||||
|
||||
class WriteCallRenderComponent extends Text {
|
||||
cache?: WriteHighlightCache;
|
||||
|
||||
constructor() {
|
||||
super("", 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
const WRITE_PARTIAL_FULL_HIGHLIGHT_LINES = 50;
|
||||
|
||||
function highlightSingleLine(line: string, lang: string): string {
|
||||
const highlighted = highlightCode(line, lang);
|
||||
return highlighted[0] ?? "";
|
||||
}
|
||||
|
||||
function refreshWriteHighlightPrefix(cache: WriteHighlightCache): void {
|
||||
const prefixCount = Math.min(WRITE_PARTIAL_FULL_HIGHLIGHT_LINES, cache.normalizedLines.length);
|
||||
if (prefixCount === 0) return;
|
||||
const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n");
|
||||
const prefixHighlighted = highlightCode(prefixSource, cache.lang);
|
||||
for (let i = 0; i < prefixCount; i++) {
|
||||
cache.highlightedLines[i] =
|
||||
prefixHighlighted[i] ?? highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang);
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildWriteHighlightCacheFull(rawPath: string | null, fileContent: string): WriteHighlightCache | undefined {
|
||||
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
|
||||
if (!lang) return undefined;
|
||||
const displayContent = normalizeDisplayText(fileContent);
|
||||
const normalized = replaceTabs(displayContent);
|
||||
return {
|
||||
rawPath,
|
||||
lang,
|
||||
rawContent: fileContent,
|
||||
normalizedLines: normalized.split("\n"),
|
||||
highlightedLines: highlightCode(normalized, lang),
|
||||
};
|
||||
}
|
||||
|
||||
function updateWriteHighlightCacheIncremental(
|
||||
cache: WriteHighlightCache | undefined,
|
||||
rawPath: string | null,
|
||||
fileContent: string,
|
||||
): WriteHighlightCache | undefined {
|
||||
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
|
||||
if (!lang) return undefined;
|
||||
if (!cache) return rebuildWriteHighlightCacheFull(rawPath, fileContent);
|
||||
if (cache.lang !== lang || cache.rawPath !== rawPath) return rebuildWriteHighlightCacheFull(rawPath, fileContent);
|
||||
if (!fileContent.startsWith(cache.rawContent)) return rebuildWriteHighlightCacheFull(rawPath, fileContent);
|
||||
if (fileContent.length === cache.rawContent.length) return cache;
|
||||
|
||||
const deltaRaw = fileContent.slice(cache.rawContent.length);
|
||||
const deltaDisplay = normalizeDisplayText(deltaRaw);
|
||||
const deltaNormalized = replaceTabs(deltaDisplay);
|
||||
cache.rawContent = fileContent;
|
||||
if (cache.normalizedLines.length === 0) {
|
||||
cache.normalizedLines.push("");
|
||||
cache.highlightedLines.push("");
|
||||
}
|
||||
|
||||
const segments = deltaNormalized.split("\n");
|
||||
const lastIndex = cache.normalizedLines.length - 1;
|
||||
cache.normalizedLines[lastIndex] += segments[0];
|
||||
cache.highlightedLines[lastIndex] = highlightSingleLine(cache.normalizedLines[lastIndex], cache.lang);
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
cache.normalizedLines.push(segments[i]);
|
||||
cache.highlightedLines.push(highlightSingleLine(segments[i], cache.lang));
|
||||
}
|
||||
refreshWriteHighlightPrefix(cache);
|
||||
return cache;
|
||||
}
|
||||
|
||||
function trimTrailingEmptyLines(lines: string[]): string[] {
|
||||
let end = lines.length;
|
||||
while (end > 0 && lines[end - 1] === "") {
|
||||
end--;
|
||||
}
|
||||
return lines.slice(0, end);
|
||||
}
|
||||
|
||||
function formatWriteCall(
|
||||
args: { path?: string; file_path?: string; content?: string } | undefined,
|
||||
options: ToolRenderResultOptions,
|
||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
||||
cache: WriteHighlightCache | undefined,
|
||||
): string {
|
||||
const rawPath = str(args?.file_path ?? args?.path);
|
||||
const fileContent = str(args?.content);
|
||||
const path = rawPath !== null ? shortenPath(rawPath) : null;
|
||||
const invalidArg = invalidArgText(theme);
|
||||
let text = `${theme.fg("toolTitle", theme.bold("write"))} ${path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")}`;
|
||||
|
||||
if (fileContent === null) {
|
||||
text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`;
|
||||
} else if (fileContent) {
|
||||
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
|
||||
const renderedLines = lang
|
||||
? (cache?.highlightedLines ?? highlightCode(replaceTabs(normalizeDisplayText(fileContent)), lang))
|
||||
: normalizeDisplayText(fileContent).split("\n");
|
||||
const lines = trimTrailingEmptyLines(renderedLines);
|
||||
const totalLines = lines.length;
|
||||
const maxLines = options.expanded ? lines.length : 10;
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
const remaining = lines.length - maxLines;
|
||||
text += `\n\n${displayLines.map((line) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`;
|
||||
if (remaining > 0) {
|
||||
text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")})`;
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function formatWriteResult(
|
||||
result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean },
|
||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
||||
): string | undefined {
|
||||
if (!result.isError) {
|
||||
return undefined;
|
||||
}
|
||||
const output = result.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text || "")
|
||||
.join("\n");
|
||||
if (!output) {
|
||||
return undefined;
|
||||
}
|
||||
return `\n${theme.fg("error", output)}`;
|
||||
}
|
||||
|
||||
export function createWriteToolDefinition(
|
||||
cwd: string,
|
||||
options?: WriteToolOptions,
|
||||
): ToolDefinition<typeof writeSchema, undefined> {
|
||||
const ops = options?.operations ?? defaultWriteOperations;
|
||||
return {
|
||||
name: "write",
|
||||
label: "write",
|
||||
description:
|
||||
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
|
||||
promptSnippet: "Create or overwrite files",
|
||||
promptGuidelines: ["Use write only for new files or complete rewrites."],
|
||||
parameters: writeSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
async execute(
|
||||
_toolCallId,
|
||||
{ path, content }: { path: string; content: string },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
_onUpdate?,
|
||||
_ctx?,
|
||||
) {
|
||||
const absolutePath = resolveToCwd(path, cwd);
|
||||
const dir = dirname(absolutePath);
|
||||
|
||||
return withFileMutationQueue(
|
||||
absolutePath,
|
||||
() =>
|
||||
new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>(
|
||||
(resolve, reject) => {
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
let aborted = false;
|
||||
|
||||
// Set up abort handler
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new Error("Operation aborted"));
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
// Perform the write operation
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
(async () => {
|
||||
try {
|
||||
// Create parent directories if needed
|
||||
// Create parent directories if needed.
|
||||
await ops.mkdir(dir);
|
||||
|
||||
// Check if aborted before writing
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Write the file
|
||||
if (aborted) return;
|
||||
// Write the file contents.
|
||||
await ops.writeFile(absolutePath, content);
|
||||
|
||||
// Check if aborted after writing
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (aborted) return;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve({
|
||||
content: [
|
||||
{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` },
|
||||
@@ -104,22 +231,55 @@ export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentT
|
||||
details: undefined,
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (!aborted) {
|
||||
reject(error);
|
||||
}
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (!aborted) reject(error);
|
||||
}
|
||||
})();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
renderCall(args, theme, context) {
|
||||
const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined;
|
||||
const rawPath = str(renderArgs?.file_path ?? renderArgs?.path);
|
||||
const fileContent = str(renderArgs?.content);
|
||||
const component =
|
||||
(context.lastComponent as WriteCallRenderComponent | undefined) ?? new WriteCallRenderComponent();
|
||||
if (fileContent !== null) {
|
||||
component.cache = context.argsComplete
|
||||
? rebuildWriteHighlightCacheFull(rawPath, fileContent)
|
||||
: updateWriteHighlightCacheIncremental(component.cache, rawPath, fileContent);
|
||||
} else {
|
||||
component.cache = undefined;
|
||||
}
|
||||
component.setText(
|
||||
formatWriteCall(
|
||||
renderArgs,
|
||||
{ expanded: context.expanded, isPartial: context.isPartial },
|
||||
theme,
|
||||
component.cache,
|
||||
),
|
||||
);
|
||||
return component;
|
||||
},
|
||||
renderResult(result, _options, theme, context) {
|
||||
const output = formatWriteResult({ ...result, isError: context.isError }, theme);
|
||||
if (!output) {
|
||||
const component = (context.lastComponent as Container | undefined) ?? new Container();
|
||||
component.clear();
|
||||
return component;
|
||||
}
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
text.setText(output);
|
||||
return text;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Default write tool using process.cwd() - for backwards compatibility */
|
||||
export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool<typeof writeSchema> {
|
||||
return wrapToolDefinition(createWriteToolDefinition(cwd, options));
|
||||
}
|
||||
|
||||
/** Default write tool using process.cwd() for backwards compatibility. */
|
||||
export const writeToolDefinition = createWriteToolDefinition(process.cwd());
|
||||
export const writeTool = createWriteTool(process.cwd());
|
||||
|
||||
@@ -223,8 +223,16 @@ export {
|
||||
type BashToolInput,
|
||||
type BashToolOptions,
|
||||
bashTool,
|
||||
bashToolDefinition,
|
||||
codingTools,
|
||||
createBashToolDefinition,
|
||||
createEditToolDefinition,
|
||||
createFindToolDefinition,
|
||||
createGrepToolDefinition,
|
||||
createLocalBashOperations,
|
||||
createLsToolDefinition,
|
||||
createReadToolDefinition,
|
||||
createWriteToolDefinition,
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_LINES,
|
||||
type EditOperations,
|
||||
@@ -232,27 +240,32 @@ export {
|
||||
type EditToolInput,
|
||||
type EditToolOptions,
|
||||
editTool,
|
||||
editToolDefinition,
|
||||
type FindOperations,
|
||||
type FindToolDetails,
|
||||
type FindToolInput,
|
||||
type FindToolOptions,
|
||||
findTool,
|
||||
findToolDefinition,
|
||||
formatSize,
|
||||
type GrepOperations,
|
||||
type GrepToolDetails,
|
||||
type GrepToolInput,
|
||||
type GrepToolOptions,
|
||||
grepTool,
|
||||
grepToolDefinition,
|
||||
type LsOperations,
|
||||
type LsToolDetails,
|
||||
type LsToolInput,
|
||||
type LsToolOptions,
|
||||
lsTool,
|
||||
lsToolDefinition,
|
||||
type ReadOperations,
|
||||
type ReadToolDetails,
|
||||
type ReadToolInput,
|
||||
type ReadToolOptions,
|
||||
readTool,
|
||||
readToolDefinition,
|
||||
type ToolsOptions,
|
||||
type TruncationOptions,
|
||||
type TruncationResult,
|
||||
@@ -264,6 +277,7 @@ export {
|
||||
type WriteToolOptions,
|
||||
withFileMutationQueue,
|
||||
writeTool,
|
||||
writeToolDefinition,
|
||||
} from "./core/tools/index.js";
|
||||
// Main entry point
|
||||
export { main } from "./main.js";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1181,9 +1181,7 @@ export class InteractiveMode {
|
||||
* Get a registered tool definition by name (for custom rendering).
|
||||
*/
|
||||
private getRegisteredToolDefinition(toolName: string) {
|
||||
const tools = this.session.extensionRunner?.getAllRegisteredTools() ?? [];
|
||||
const registeredTool = tools.find((t) => t.definition.name === toolName);
|
||||
return registeredTool?.definition;
|
||||
return this.session.getToolDefinition(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2198,6 +2196,7 @@ export class InteractiveMode {
|
||||
if (!this.pendingTools.has(content.id)) {
|
||||
const component = new ToolExecutionComponent(
|
||||
content.name,
|
||||
content.id,
|
||||
content.arguments,
|
||||
{
|
||||
showImages: this.settingsManager.getShowImages(),
|
||||
@@ -2264,6 +2263,7 @@ export class InteractiveMode {
|
||||
if (!component) {
|
||||
component = new ToolExecutionComponent(
|
||||
event.toolName,
|
||||
event.toolCallId,
|
||||
event.args,
|
||||
{
|
||||
showImages: this.settingsManager.getShowImages(),
|
||||
@@ -2568,6 +2568,7 @@ export class InteractiveMode {
|
||||
if (content.type === "toolCall") {
|
||||
const component = new ToolExecutionComponent(
|
||||
content.name,
|
||||
content.id,
|
||||
content.arguments,
|
||||
{ showImages: this.settingsManager.getShowImages() },
|
||||
this.getRegisteredToolDefinition(content.name),
|
||||
|
||||
@@ -25,8 +25,14 @@ describe("buildSystemPrompt", () => {
|
||||
});
|
||||
|
||||
describe("default tools", () => {
|
||||
test("includes all default tools", () => {
|
||||
test("includes all default tools when snippets are provided", () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
toolSnippets: {
|
||||
read: "Read file contents",
|
||||
bash: "Execute bash commands",
|
||||
edit: "Make surgical edits",
|
||||
write: "Create or overwrite files",
|
||||
},
|
||||
contextFiles: [],
|
||||
skills: [],
|
||||
});
|
||||
|
||||
@@ -3,13 +3,16 @@ import { Type } from "@sinclair/typebox";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import type { ToolDefinition } from "../src/core/extensions/types.js";
|
||||
import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.js";
|
||||
import { createReadToolDefinition } from "../src/core/tools/read.js";
|
||||
import { createWriteToolDefinition } from "../src/core/tools/write.js";
|
||||
import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.js";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.js";
|
||||
|
||||
function createBaseToolDefinition(): ToolDefinition {
|
||||
function createBaseToolDefinition(name = "custom_tool"): ToolDefinition {
|
||||
return {
|
||||
name: "custom_tool",
|
||||
label: "custom_tool",
|
||||
name,
|
||||
label: name,
|
||||
description: "custom tool",
|
||||
parameters: Type.Any(),
|
||||
execute: async () => ({
|
||||
@@ -25,48 +28,235 @@ function createFakeTui(): TUI {
|
||||
} as unknown as TUI;
|
||||
}
|
||||
|
||||
describe("ToolExecutionComponent custom renderer suppression", () => {
|
||||
describe("ToolExecutionComponent parity", () => {
|
||||
beforeAll(() => {
|
||||
initTheme("dark");
|
||||
});
|
||||
|
||||
test("renders no lines when custom renderers return undefined", () => {
|
||||
test("stacks custom call and result renderers like the old implementation", () => {
|
||||
const toolDefinition: ToolDefinition = {
|
||||
...createBaseToolDefinition(),
|
||||
renderCall: () => undefined,
|
||||
renderResult: () => undefined,
|
||||
renderCall: () => new Text("custom call", 0, 0),
|
||||
renderResult: () => new Text("custom result", 0, 0),
|
||||
};
|
||||
|
||||
const component = new ToolExecutionComponent("custom_tool", {}, {}, toolDefinition, createFakeTui());
|
||||
expect(component.render(120)).toEqual([]);
|
||||
const component = new ToolExecutionComponent("custom_tool", "tool-1", {}, {}, toolDefinition, createFakeTui());
|
||||
expect(stripAnsi(component.render(120).join("\n"))).toContain("custom call");
|
||||
|
||||
component.updateResult(
|
||||
{
|
||||
content: [{ type: "text", text: "hidden" }],
|
||||
content: [{ type: "text", text: "done" }],
|
||||
details: {},
|
||||
isError: false,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(component.render(120)).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps built-in tool rendering visible", () => {
|
||||
const component = new ToolExecutionComponent("read", { path: "README.md" }, {}, undefined, createFakeTui());
|
||||
const rendered = stripAnsi(component.render(120).join("\n"));
|
||||
expect(rendered).toContain("read");
|
||||
});
|
||||
|
||||
test("keeps custom tool rendering visible when renderer returns a component", () => {
|
||||
const toolDefinition: ToolDefinition = {
|
||||
...createBaseToolDefinition(),
|
||||
renderCall: () => new Text("custom call", 0, 0),
|
||||
renderResult: () => undefined,
|
||||
};
|
||||
|
||||
const component = new ToolExecutionComponent("custom_tool", {}, {}, toolDefinition, createFakeTui());
|
||||
const rendered = stripAnsi(component.render(120).join("\n"));
|
||||
expect(rendered).toContain("custom call");
|
||||
expect(rendered).toContain("custom result");
|
||||
});
|
||||
|
||||
test("uses built-in rendering for built-in overrides without custom renderers", () => {
|
||||
const overrideDefinition: ToolDefinition = {
|
||||
...createBaseToolDefinition("edit"),
|
||||
};
|
||||
|
||||
const component = new ToolExecutionComponent(
|
||||
"edit",
|
||||
"tool-2",
|
||||
{ path: "README.md", oldText: "before", newText: "after" },
|
||||
{},
|
||||
overrideDefinition,
|
||||
createFakeTui(),
|
||||
);
|
||||
component.updateResult({ content: [], details: { diff: "+1 after", firstChangedLine: 1 }, isError: false });
|
||||
const rendered = stripAnsi(component.render(120).join("\n"));
|
||||
expect(rendered).toContain("edit");
|
||||
expect(rendered).toContain("README.md");
|
||||
expect(rendered).not.toContain(":1");
|
||||
});
|
||||
|
||||
test("preserves legacy file_path rendering compatibility for built-in tools", () => {
|
||||
const component = new ToolExecutionComponent(
|
||||
"read",
|
||||
"tool-3",
|
||||
{ file_path: "README.md" },
|
||||
{},
|
||||
undefined,
|
||||
createFakeTui(),
|
||||
);
|
||||
const rendered = stripAnsi(component.render(120).join("\n"));
|
||||
expect(rendered).toContain("read");
|
||||
expect(rendered).toContain("README.md");
|
||||
});
|
||||
|
||||
test("bash execute emits an initial empty partial update before output arrives", async () => {
|
||||
const updates: Array<{ content: Array<{ type: string; text?: string }>; details?: unknown }> = [];
|
||||
const operations: BashOperations = {
|
||||
exec: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
};
|
||||
const tool = createBashToolDefinition(process.cwd(), { operations });
|
||||
const promise = tool.execute(
|
||||
"tool-bash-1",
|
||||
{ command: "sleep 10" },
|
||||
undefined,
|
||||
(update) => updates.push(update as { content: Array<{ type: string; text?: string }>; details?: unknown }),
|
||||
{} as never,
|
||||
);
|
||||
expect(updates).toEqual([{ content: [], details: undefined }]);
|
||||
await promise;
|
||||
});
|
||||
|
||||
test("does not duplicate built-in headers when passed the active built-in definition", () => {
|
||||
const component = new ToolExecutionComponent(
|
||||
"read",
|
||||
"tool-4",
|
||||
{ path: "README.md" },
|
||||
{},
|
||||
createReadToolDefinition(process.cwd()),
|
||||
createFakeTui(),
|
||||
);
|
||||
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
|
||||
const rendered = stripAnsi(component.render(120).join("\n"));
|
||||
expect(rendered.match(/\bread\b/g)?.length ?? 0).toBe(1);
|
||||
});
|
||||
|
||||
test("inherits missing built-in result renderer slot from the built-in tool", () => {
|
||||
const overrideDefinition: ToolDefinition = {
|
||||
...createBaseToolDefinition("read"),
|
||||
renderCall: () => new Text("override call", 0, 0),
|
||||
};
|
||||
|
||||
const component = new ToolExecutionComponent(
|
||||
"read",
|
||||
"tool-4b",
|
||||
{ path: "README.md" },
|
||||
{},
|
||||
overrideDefinition,
|
||||
createFakeTui(),
|
||||
);
|
||||
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
|
||||
const rendered = stripAnsi(component.render(120).join("\n"));
|
||||
expect(rendered).toContain("override call");
|
||||
expect(rendered).toContain("hello");
|
||||
});
|
||||
|
||||
test("inherits missing built-in call renderer slot from the built-in tool", () => {
|
||||
const overrideDefinition: ToolDefinition = {
|
||||
...createBaseToolDefinition("read"),
|
||||
renderResult: () => new Text("override result", 0, 0),
|
||||
};
|
||||
|
||||
const component = new ToolExecutionComponent(
|
||||
"read",
|
||||
"tool-4c",
|
||||
{ path: "README.md" },
|
||||
{},
|
||||
overrideDefinition,
|
||||
createFakeTui(),
|
||||
);
|
||||
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
|
||||
const rendered = stripAnsi(component.render(120).join("\n"));
|
||||
expect(rendered).toContain("read");
|
||||
expect(rendered).toContain("README.md");
|
||||
expect(rendered).toContain("override result");
|
||||
});
|
||||
|
||||
test("shares renderer state across custom call and result slots", () => {
|
||||
type RenderState = { token?: string };
|
||||
const toolDefinition: ToolDefinition<any, unknown, RenderState> = {
|
||||
...createBaseToolDefinition(),
|
||||
renderCall: (_args, _theme, context) => {
|
||||
context.state.token ??= "shared-token";
|
||||
return new Text(`custom call ${context.state.token}`, 0, 0);
|
||||
},
|
||||
renderResult: (_result, _options, _theme, context) => {
|
||||
return new Text(`custom result ${context.state.token}`, 0, 0);
|
||||
},
|
||||
};
|
||||
|
||||
const component = new ToolExecutionComponent("custom_tool", "tool-5", {}, {}, toolDefinition, createFakeTui());
|
||||
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
|
||||
const rendered = stripAnsi(component.render(120).join("\n"));
|
||||
expect(rendered).toContain("custom call shared-token");
|
||||
expect(rendered).toContain("custom result shared-token");
|
||||
});
|
||||
|
||||
test("exposes args in render result context", () => {
|
||||
const toolDefinition: ToolDefinition = {
|
||||
...createBaseToolDefinition(),
|
||||
renderCall: () => new Text("call", 0, 0),
|
||||
renderResult: (_result, _options, _theme, context) =>
|
||||
new Text(`arg:${String((context.args as { foo: string }).foo)}`, 0, 0),
|
||||
};
|
||||
|
||||
const component = new ToolExecutionComponent(
|
||||
"custom_tool",
|
||||
"tool-5b",
|
||||
{ foo: "bar" },
|
||||
{},
|
||||
toolDefinition,
|
||||
createFakeTui(),
|
||||
);
|
||||
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
|
||||
const rendered = stripAnsi(component.render(120).join("\n"));
|
||||
expect(rendered).toContain("arg:bar");
|
||||
});
|
||||
|
||||
test("falls back when custom renderers are absent", () => {
|
||||
const toolDefinition: ToolDefinition = {
|
||||
...createBaseToolDefinition(),
|
||||
};
|
||||
|
||||
const component = new ToolExecutionComponent(
|
||||
"custom_tool",
|
||||
"tool-6",
|
||||
{ foo: "bar" },
|
||||
{},
|
||||
toolDefinition,
|
||||
createFakeTui(),
|
||||
);
|
||||
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
|
||||
const rendered = stripAnsi(component.render(120).join("\n"));
|
||||
expect(rendered).toContain("custom_tool");
|
||||
expect(rendered).toContain("done");
|
||||
});
|
||||
|
||||
test("trims trailing blank display lines from write previews", () => {
|
||||
const component = new ToolExecutionComponent(
|
||||
"write",
|
||||
"tool-7",
|
||||
{ path: "README.md", content: "one\ntwo\n" },
|
||||
{},
|
||||
createWriteToolDefinition(process.cwd()),
|
||||
createFakeTui(),
|
||||
);
|
||||
const rendered = stripAnsi(component.render(120).join("\n"));
|
||||
expect(rendered).toContain("one");
|
||||
expect(rendered).toContain("two");
|
||||
expect(rendered).not.toContain("two\n\n");
|
||||
});
|
||||
|
||||
test("trims trailing blank display lines from read results", () => {
|
||||
const component = new ToolExecutionComponent(
|
||||
"read",
|
||||
"tool-8",
|
||||
{ path: "README.md" },
|
||||
{},
|
||||
createReadToolDefinition(process.cwd()),
|
||||
createFakeTui(),
|
||||
);
|
||||
component.updateResult(
|
||||
{ content: [{ type: "text", text: "one\ntwo\n" }], details: undefined, isError: false },
|
||||
false,
|
||||
);
|
||||
const rendered = stripAnsi(component.render(120).join("\n"));
|
||||
expect(rendered).toContain("one");
|
||||
expect(rendered).toContain("two");
|
||||
expect(rendered).not.toContain("two\n\n");
|
||||
});
|
||||
});
|
||||
|
||||
834
scripts/edit-tool-stats.mjs
Normal file
834
scripts/edit-tool-stats.mjs
Normal file
@@ -0,0 +1,834 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createReadStream } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
const DEFAULT_SESSIONS_DIR = path.join(homedir(), ".pi/agent/sessions");
|
||||
const DEFAULT_ACTIVE_EDIT_EXTENSION_PATH = path.join(homedir(), ".pi/agent/extensions/edit.ts");
|
||||
const DEFAULT_TOP = 20;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
sessionsDir: DEFAULT_SESSIONS_DIR,
|
||||
json: false,
|
||||
includeRecords: false,
|
||||
failedOnly: false,
|
||||
modelFilter: undefined,
|
||||
extFilter: undefined,
|
||||
top: DEFAULT_TOP,
|
||||
help: false,
|
||||
allSessions: false,
|
||||
since: undefined,
|
||||
autoSincePath: DEFAULT_ACTIVE_EDIT_EXTENSION_PATH,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
options.help = true;
|
||||
} else if (arg === "--json") {
|
||||
options.json = true;
|
||||
} else if (arg === "--include-records") {
|
||||
options.includeRecords = true;
|
||||
} else if (arg === "--failed-only") {
|
||||
options.failedOnly = true;
|
||||
} else if (arg === "--model") {
|
||||
options.modelFilter = argv[++i];
|
||||
} else if (arg === "--ext") {
|
||||
options.extFilter = argv[++i]?.toLowerCase();
|
||||
} else if (arg === "--top") {
|
||||
const value = Number.parseInt(argv[++i] ?? "", 10);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error("--top must be a positive integer");
|
||||
}
|
||||
options.top = value;
|
||||
} else if (arg === "--sessions-dir") {
|
||||
options.sessionsDir = argv[++i];
|
||||
} else if (arg === "--all-sessions") {
|
||||
options.allSessions = true;
|
||||
} else if (arg === "--since") {
|
||||
options.since = argv[++i];
|
||||
} else if (arg === "--auto-since-path") {
|
||||
options.autoSincePath = argv[++i];
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/edit-tool-stats.mjs [options]
|
||||
|
||||
Options:
|
||||
--sessions-dir <path> Sessions directory (default: ~/.pi/agent/sessions)
|
||||
--model <substring> Filter provider/model by substring
|
||||
--ext <extension> Filter by file extension, e.g. .ts
|
||||
--failed-only Include only failed edit calls
|
||||
--top <n> Number of examples to show (default: ${DEFAULT_TOP})
|
||||
--since <iso> Only scan session files created at or after this ISO time
|
||||
--all-sessions Disable the automatic since filter
|
||||
--auto-since-path <p> Use birth time of this file for the automatic since filter
|
||||
--json Print JSON summary instead of human report
|
||||
--include-records Include raw records in JSON output
|
||||
-h, --help Show this help
|
||||
`);
|
||||
}
|
||||
|
||||
function parseSessionFileTimestamp(sessionFile) {
|
||||
const base = path.basename(sessionFile);
|
||||
const rawTimestamp = base.split("_")[0];
|
||||
if (!rawTimestamp) return null;
|
||||
const isoTimestamp = rawTimestamp.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "T$1:$2:$3.$4Z");
|
||||
const ms = Date.parse(isoTimestamp);
|
||||
if (!Number.isFinite(ms)) return null;
|
||||
return ms;
|
||||
}
|
||||
|
||||
function formatIso(ms) {
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
async function resolveAutoSinceMs(options) {
|
||||
if (options.allSessions) return null;
|
||||
if (options.since) {
|
||||
const ms = Date.parse(options.since);
|
||||
if (!Number.isFinite(ms)) {
|
||||
throw new Error(`Invalid --since value: ${options.since}`);
|
||||
}
|
||||
return { ms, source: `--since ${options.since}` };
|
||||
}
|
||||
if (!options.autoSincePath) return null;
|
||||
try {
|
||||
const stats = await fs.stat(options.autoSincePath);
|
||||
const birthtimeMs = Number.isFinite(stats.birthtimeMs) && stats.birthtimeMs > 0 ? stats.birthtimeMs : stats.mtimeMs;
|
||||
if (!Number.isFinite(birthtimeMs) || birthtimeMs <= 0) return null;
|
||||
return { ms: birthtimeMs, source: `birth time of ${options.autoSincePath}` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function* walkJsonlFiles(dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
yield* walkJsonlFiles(fullPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
||||
yield fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPathExtension(filePath) {
|
||||
if (typeof filePath !== "string" || filePath.length === 0) return "[unknown]";
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext) return ext;
|
||||
const base = path.basename(filePath);
|
||||
if (base.startsWith(".") && !base.slice(1).includes(".")) return base.toLowerCase();
|
||||
return "[no_ext]";
|
||||
}
|
||||
|
||||
function utf8Bytes(value) {
|
||||
return Buffer.byteLength(value ?? "", "utf8");
|
||||
}
|
||||
|
||||
function longestCommonPrefixLength(a, b) {
|
||||
const max = Math.min(a.length, b.length);
|
||||
let index = 0;
|
||||
while (index < max && a[index] === b[index]) index++;
|
||||
return index;
|
||||
}
|
||||
|
||||
function longestCommonSuffixLength(a, b) {
|
||||
const max = Math.min(a.length, b.length);
|
||||
let index = 0;
|
||||
while (index < max && a[a.length - 1 - index] === b[b.length - 1 - index]) index++;
|
||||
return index;
|
||||
}
|
||||
|
||||
function analyzeReplacement(oldText, newText) {
|
||||
const prefixChars = longestCommonPrefixLength(oldText, newText);
|
||||
const oldRemainder = oldText.slice(prefixChars);
|
||||
const newRemainder = newText.slice(prefixChars);
|
||||
const suffixChars = longestCommonSuffixLength(oldRemainder, newRemainder);
|
||||
const oldCore = suffixChars > 0 ? oldRemainder.slice(0, -suffixChars) : oldRemainder;
|
||||
const newCore = suffixChars > 0 ? newRemainder.slice(0, -suffixChars) : newRemainder;
|
||||
const prefix = oldText.slice(0, prefixChars);
|
||||
const suffix = suffixChars > 0 ? oldRemainder.slice(-suffixChars) : "";
|
||||
|
||||
const oldBytes = utf8Bytes(oldText);
|
||||
const newBytes = utf8Bytes(newText);
|
||||
const sharedPrefixBytes = utf8Bytes(prefix);
|
||||
const sharedSuffixBytes = utf8Bytes(suffix);
|
||||
const sharedContextBytes = sharedPrefixBytes + sharedSuffixBytes;
|
||||
const coreOldBytes = utf8Bytes(oldCore);
|
||||
const coreNewBytes = utf8Bytes(newCore);
|
||||
const coreBytes = coreOldBytes + coreNewBytes;
|
||||
const totalEditBytes = oldBytes + newBytes;
|
||||
const wrapperPayloadBytes = totalEditBytes - coreBytes;
|
||||
const inflationRatio = coreBytes === 0 ? null : totalEditBytes / coreBytes;
|
||||
|
||||
return {
|
||||
oldBytes,
|
||||
newBytes,
|
||||
totalEditBytes,
|
||||
sharedPrefixBytes,
|
||||
sharedSuffixBytes,
|
||||
sharedContextBytes,
|
||||
coreOldBytes,
|
||||
coreNewBytes,
|
||||
coreBytes,
|
||||
wrapperPayloadBytes,
|
||||
inflationRatio,
|
||||
noCoreChange: coreBytes === 0,
|
||||
};
|
||||
}
|
||||
|
||||
function median(numbers) {
|
||||
return quantile(numbers, 0.5);
|
||||
}
|
||||
|
||||
function quantile(numbers, q) {
|
||||
const finite = numbers.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
||||
if (finite.length === 0) return null;
|
||||
if (finite.length === 1) return finite[0];
|
||||
const position = (finite.length - 1) * q;
|
||||
const lower = Math.floor(position);
|
||||
const upper = Math.ceil(position);
|
||||
if (lower === upper) return finite[lower];
|
||||
const weight = position - lower;
|
||||
return finite[lower] * (1 - weight) + finite[upper] * weight;
|
||||
}
|
||||
|
||||
function formatInt(value) {
|
||||
return new Intl.NumberFormat("en-US").format(value);
|
||||
}
|
||||
|
||||
function formatPercent(part, total) {
|
||||
if (total === 0) return "n/a";
|
||||
return `${((part / total) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatRatio(value) {
|
||||
if (value === null) return "no-core-change";
|
||||
if (!Number.isFinite(value)) return "∞";
|
||||
if (value >= 100) return `${value.toFixed(0)}x`;
|
||||
if (value >= 10) return `${value.toFixed(1)}x`;
|
||||
return `${value.toFixed(2)}x`;
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
if (value < 1024) return `${value}B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(value >= 10 * 1024 ? 0 : 1)}KB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
function extractTextContent(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((block) => block?.type === "text" && typeof block.text === "string")
|
||||
.map((block) => block.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function classifyErrorKind(text, isError, matchedResult) {
|
||||
if (!matchedResult) return "missing_result";
|
||||
if (!isError) return null;
|
||||
const normalized = text.toLowerCase();
|
||||
if (normalized.includes("file not found")) return "file_not_found";
|
||||
if (normalized.includes("could not find the exact text")) return "not_found_exact_text";
|
||||
if (normalized.includes("found multiple occurrences") || /^found \d+ occurrences/m.test(normalized)) {
|
||||
return "multiple_occurrences";
|
||||
}
|
||||
if (normalized.includes("no changes made")) return "no_changes_made";
|
||||
if (normalized.includes("input is invalid")) return "invalid_input";
|
||||
if (normalized.includes("must not overlap")) return "overlapping_edits";
|
||||
if (normalized.includes("aborted")) return "aborted";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function getArgStyle(args) {
|
||||
const hasEdits = Array.isArray(args?.edits);
|
||||
const hasOldText = typeof args?.oldText === "string" || typeof args?.newText === "string";
|
||||
const hasOldString = typeof args?.old_string === "string" || typeof args?.new_string === "string";
|
||||
if (hasEdits) return "edits";
|
||||
if (hasOldText && hasOldString) return "mixed";
|
||||
if (hasOldText) return "oldText/newText";
|
||||
if (hasOldString) return "old_string/new_string";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function analyzeToolArguments(args) {
|
||||
const normalizedArgs = args && typeof args === "object" ? args : {};
|
||||
const filePath = typeof normalizedArgs.path === "string" ? normalizedArgs.path : "";
|
||||
const extension = getPathExtension(filePath);
|
||||
const argStyle = getArgStyle(normalizedArgs);
|
||||
|
||||
if (Array.isArray(normalizedArgs.edits)) {
|
||||
const perEdit = normalizedArgs.edits.map((edit) =>
|
||||
analyzeReplacement(typeof edit?.oldText === "string" ? edit.oldText : "", typeof edit?.newText === "string" ? edit.newText : "")
|
||||
);
|
||||
const inflations = perEdit.map((edit) => edit.inflationRatio).filter((value) => value !== null);
|
||||
const totals = perEdit.reduce(
|
||||
(acc, edit) => ({
|
||||
oldBytes: acc.oldBytes + edit.oldBytes,
|
||||
newBytes: acc.newBytes + edit.newBytes,
|
||||
totalEditBytes: acc.totalEditBytes + edit.totalEditBytes,
|
||||
sharedPrefixBytes: acc.sharedPrefixBytes + edit.sharedPrefixBytes,
|
||||
sharedSuffixBytes: acc.sharedSuffixBytes + edit.sharedSuffixBytes,
|
||||
sharedContextBytes: acc.sharedContextBytes + edit.sharedContextBytes,
|
||||
coreOldBytes: acc.coreOldBytes + edit.coreOldBytes,
|
||||
coreNewBytes: acc.coreNewBytes + edit.coreNewBytes,
|
||||
coreBytes: acc.coreBytes + edit.coreBytes,
|
||||
wrapperPayloadBytes: acc.wrapperPayloadBytes + edit.wrapperPayloadBytes,
|
||||
noCoreChangeCount: acc.noCoreChangeCount + (edit.noCoreChange ? 1 : 0),
|
||||
}),
|
||||
{
|
||||
oldBytes: 0,
|
||||
newBytes: 0,
|
||||
totalEditBytes: 0,
|
||||
sharedPrefixBytes: 0,
|
||||
sharedSuffixBytes: 0,
|
||||
sharedContextBytes: 0,
|
||||
coreOldBytes: 0,
|
||||
coreNewBytes: 0,
|
||||
coreBytes: 0,
|
||||
wrapperPayloadBytes: 0,
|
||||
noCoreChangeCount: 0,
|
||||
}
|
||||
);
|
||||
return {
|
||||
path: filePath,
|
||||
extension,
|
||||
mode: "multi",
|
||||
argStyle,
|
||||
editsCount: normalizedArgs.edits.length,
|
||||
...totals,
|
||||
inflationRatio: totals.coreBytes === 0 ? null : totals.totalEditBytes / totals.coreBytes,
|
||||
medianEditInflationRatio: median(inflations),
|
||||
maxEditInflationRatio: inflations.length > 0 ? Math.max(...inflations) : null,
|
||||
perEdit,
|
||||
};
|
||||
}
|
||||
|
||||
const oldText = typeof normalizedArgs.oldText === "string" ? normalizedArgs.oldText : typeof normalizedArgs.old_string === "string" ? normalizedArgs.old_string : "";
|
||||
const newText = typeof normalizedArgs.newText === "string" ? normalizedArgs.newText : typeof normalizedArgs.new_string === "string" ? normalizedArgs.new_string : "";
|
||||
const replacement = analyzeReplacement(oldText, newText);
|
||||
return {
|
||||
path: filePath,
|
||||
extension,
|
||||
mode: "single",
|
||||
argStyle,
|
||||
editsCount: 1,
|
||||
...replacement,
|
||||
medianEditInflationRatio: replacement.inflationRatio,
|
||||
maxEditInflationRatio: replacement.inflationRatio,
|
||||
perEdit: [replacement],
|
||||
};
|
||||
}
|
||||
|
||||
function groupCounts(records, keyFn) {
|
||||
const counts = new Map();
|
||||
for (const record of records) {
|
||||
const key = keyFn(record);
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function collectInflations(records) {
|
||||
return records.map((record) => record.inflationRatio).filter((value) => value !== null);
|
||||
}
|
||||
|
||||
function summarizeGroups(records, keyFn) {
|
||||
const groups = new Map();
|
||||
for (const record of records) {
|
||||
const key = keyFn(record);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(record);
|
||||
}
|
||||
|
||||
return [...groups.entries()]
|
||||
.map(([key, group]) => {
|
||||
const resolved = group.filter((record) => record.success !== null);
|
||||
const success = resolved.filter((record) => record.success).length;
|
||||
const failed = resolved.filter((record) => record.success === false).length;
|
||||
const multi = group.filter((record) => record.mode === "multi").length;
|
||||
const inflations = collectInflations(group);
|
||||
return {
|
||||
key,
|
||||
calls: group.length,
|
||||
resolved: resolved.length,
|
||||
success,
|
||||
failed,
|
||||
unresolved: group.length - resolved.length,
|
||||
multi,
|
||||
multiRate: group.length === 0 ? null : multi / group.length,
|
||||
successRate: resolved.length === 0 ? null : success / resolved.length,
|
||||
medianInflation: quantile(inflations, 0.5),
|
||||
p95Inflation: quantile(inflations, 0.95),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.calls - a.calls || a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
function buildSameFileClusterStats(records) {
|
||||
const groups = new Map();
|
||||
for (const record of records) {
|
||||
const key = `${record.sessionFile}::${record.assistantEntryId}::${record.path}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(record);
|
||||
}
|
||||
|
||||
const clusters = [...groups.values()].filter((group) => group.length >= 2);
|
||||
const assistantMessagesWithCluster = new Set(clusters.map((group) => `${group[0].sessionFile}::${group[0].assistantEntryId}`));
|
||||
const assistantMessagesWithMultiEdit = new Set(
|
||||
records
|
||||
.filter((record) => record.mode === "multi" && record.editsCount > 1)
|
||||
.map((record) => `${record.sessionFile}::${record.assistantEntryId}`)
|
||||
);
|
||||
const callsInsideClusters = clusters.reduce((sum, group) => sum + group.length, 0);
|
||||
|
||||
return {
|
||||
clustersCount: clusters.length,
|
||||
assistantMessagesWithCluster: assistantMessagesWithCluster.size,
|
||||
assistantMessagesWithMultiEdit: assistantMessagesWithMultiEdit.size,
|
||||
callsInsideClusters,
|
||||
averageCallsPerCluster: clusters.length === 0 ? null : callsInsideClusters / clusters.length,
|
||||
ratioClusterAssistantMessagesToMultiEditAssistantMessages:
|
||||
assistantMessagesWithMultiEdit.size === 0 ? null : assistantMessagesWithCluster.size / assistantMessagesWithMultiEdit.size,
|
||||
};
|
||||
}
|
||||
|
||||
function buildInflationBuckets(records) {
|
||||
const buckets = [
|
||||
{ key: "no_core_change", label: "no-core-change", test: (record) => record.inflationRatio === null },
|
||||
{ key: "lt4", label: "<4x", test: (record) => record.inflationRatio !== null && record.inflationRatio < 4 },
|
||||
{ key: "4to10", label: "4-10x", test: (record) => record.inflationRatio !== null && record.inflationRatio >= 4 && record.inflationRatio < 10 },
|
||||
{ key: "10to25", label: "10-25x", test: (record) => record.inflationRatio !== null && record.inflationRatio >= 10 && record.inflationRatio < 25 },
|
||||
{ key: "gte25", label: "25x+", test: (record) => record.inflationRatio !== null && record.inflationRatio >= 25 },
|
||||
];
|
||||
|
||||
return buckets.map((bucket) => {
|
||||
const bucketRecords = records.filter(bucket.test);
|
||||
const resolved = bucketRecords.filter((record) => record.success !== null);
|
||||
const failed = resolved.filter((record) => record.success === false).length;
|
||||
return {
|
||||
key: bucket.key,
|
||||
label: bucket.label,
|
||||
count: bucketRecords.length,
|
||||
resolved: resolved.length,
|
||||
failed,
|
||||
failureRate: resolved.length === 0 ? null : failed / resolved.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildHugeReplacementStats(records) {
|
||||
const thresholds = [1024, 4096, 16384, 65536];
|
||||
return thresholds.map((threshold) => ({
|
||||
threshold,
|
||||
count: records.filter((record) => record.totalEditBytes > threshold).length,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildWorstExamples(records, top) {
|
||||
const scored = [...records].sort((a, b) => {
|
||||
const aScore = a.inflationRatio === null ? Number.POSITIVE_INFINITY : a.inflationRatio;
|
||||
const bScore = b.inflationRatio === null ? Number.POSITIVE_INFINITY : b.inflationRatio;
|
||||
if (aScore !== bScore) return bScore - aScore;
|
||||
if (a.totalEditBytes !== b.totalEditBytes) return b.totalEditBytes - a.totalEditBytes;
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
|
||||
return scored.slice(0, top).map((record) => ({
|
||||
providerModel: record.providerModel,
|
||||
extension: record.extension,
|
||||
path: record.path,
|
||||
inflationRatio: record.inflationRatio,
|
||||
totalEditBytes: record.totalEditBytes,
|
||||
coreBytes: record.coreBytes,
|
||||
mode: record.mode,
|
||||
editsCount: record.editsCount,
|
||||
failed: record.success === false,
|
||||
errorKind: record.errorKind,
|
||||
sessionFile: record.sessionFile,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSummary(records, meta, options) {
|
||||
const uniqueAssistantMessages = new Set(records.map((record) => `${record.sessionFile}::${record.assistantEntryId}`)).size;
|
||||
const resolved = records.filter((record) => record.success !== null);
|
||||
const success = resolved.filter((record) => record.success).length;
|
||||
const failed = resolved.filter((record) => record.success === false).length;
|
||||
const unresolved = records.length - resolved.length;
|
||||
const single = records.filter((record) => record.mode === "single").length;
|
||||
const multi = records.filter((record) => record.mode === "multi").length;
|
||||
const modeStats = ["single", "multi"].map((mode) => {
|
||||
const modeRecords = records.filter((record) => record.mode === mode);
|
||||
const modeResolved = modeRecords.filter((record) => record.success !== null);
|
||||
const modeSuccess = modeResolved.filter((record) => record.success).length;
|
||||
const modeFailed = modeResolved.filter((record) => record.success === false).length;
|
||||
return {
|
||||
mode,
|
||||
calls: modeRecords.length,
|
||||
resolved: modeResolved.length,
|
||||
success: modeSuccess,
|
||||
failed: modeFailed,
|
||||
unresolved: modeRecords.length - modeResolved.length,
|
||||
successRate: modeResolved.length === 0 ? null : modeSuccess / modeResolved.length,
|
||||
failureRate: modeResolved.length === 0 ? null : modeFailed / modeResolved.length,
|
||||
};
|
||||
});
|
||||
const multiEditLengthBuckets = [
|
||||
{ key: "1", label: "edits.length === 1", test: (record) => record.mode === "multi" && record.editsCount === 1 },
|
||||
{ key: "2", label: "edits.length === 2", test: (record) => record.mode === "multi" && record.editsCount === 2 },
|
||||
{ key: "3plus", label: "edits.length >= 3", test: (record) => record.mode === "multi" && record.editsCount >= 3 },
|
||||
].map((bucket) => {
|
||||
const bucketRecords = records.filter(bucket.test);
|
||||
const bucketResolved = bucketRecords.filter((record) => record.success !== null);
|
||||
const bucketSuccess = bucketResolved.filter((record) => record.success).length;
|
||||
const bucketFailed = bucketResolved.filter((record) => record.success === false).length;
|
||||
return {
|
||||
key: bucket.key,
|
||||
label: bucket.label,
|
||||
calls: bucketRecords.length,
|
||||
resolved: bucketResolved.length,
|
||||
success: bucketSuccess,
|
||||
failed: bucketFailed,
|
||||
unresolved: bucketRecords.length - bucketResolved.length,
|
||||
successRate: bucketResolved.length === 0 ? null : bucketSuccess / bucketResolved.length,
|
||||
failureRate: bucketResolved.length === 0 ? null : bucketFailed / bucketResolved.length,
|
||||
};
|
||||
});
|
||||
const argStyles = [...groupCounts(records, (record) => record.argStyle).entries()]
|
||||
.map(([style, count]) => ({ style, count }))
|
||||
.sort((a, b) => b.count - a.count || a.style.localeCompare(b.style));
|
||||
const providerStats = summarizeGroups(records, (record) => record.providerModel);
|
||||
const extensionStats = summarizeGroups(records, (record) => record.extension);
|
||||
const inflations = collectInflations(records);
|
||||
const noCoreChange = records.filter((record) => record.inflationRatio === null).length;
|
||||
const pathologicalThresholds = [10, 25, 100].map((threshold) => ({
|
||||
threshold,
|
||||
count: records.filter((record) => record.inflationRatio !== null && record.inflationRatio >= threshold).length,
|
||||
}));
|
||||
const failureKinds = [...groupCounts(records.filter((record) => record.success === false), (record) => record.errorKind ?? "other").entries()]
|
||||
.map(([kind, count]) => ({ kind, count }))
|
||||
.sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind));
|
||||
|
||||
return {
|
||||
filters: {
|
||||
model: options.modelFilter ?? null,
|
||||
extension: options.extFilter ?? null,
|
||||
failedOnly: options.failedOnly,
|
||||
},
|
||||
scan: {
|
||||
sessionsDir: meta.sessionsDir,
|
||||
sessionFilesScanned: meta.sessionFilesScanned,
|
||||
sessionFilesIncluded: meta.sessionFilesIncluded,
|
||||
sessionFilesSkippedOlderThanSince: meta.sessionFilesSkippedOlderThanSince,
|
||||
sessionFilesWithEditCalls: meta.sessionFilesWithEditCalls,
|
||||
since: meta.since ? { ms: meta.since.ms, iso: formatIso(meta.since.ms), source: meta.since.source } : null,
|
||||
malformedLines: meta.malformedLines,
|
||||
unmatchedToolResults: meta.unmatchedToolResults,
|
||||
},
|
||||
counts: {
|
||||
assistantMessagesWithEditCalls: uniqueAssistantMessages,
|
||||
totalEditCalls: records.length,
|
||||
resolvedEditCalls: resolved.length,
|
||||
success,
|
||||
failed,
|
||||
unresolved,
|
||||
single,
|
||||
multi,
|
||||
noCoreChange,
|
||||
},
|
||||
modeStats,
|
||||
multiEditLengthBuckets,
|
||||
argStyles,
|
||||
providerStats,
|
||||
extensionStats,
|
||||
inflation: {
|
||||
median: quantile(inflations, 0.5),
|
||||
p90: quantile(inflations, 0.9),
|
||||
p95: quantile(inflations, 0.95),
|
||||
p99: quantile(inflations, 0.99),
|
||||
pathologicalThresholds,
|
||||
hugeReplacements: buildHugeReplacementStats(records),
|
||||
failureByBucket: buildInflationBuckets(records),
|
||||
},
|
||||
sameFileClusters: buildSameFileClusterStats(records),
|
||||
failureKinds,
|
||||
worstExamples: buildWorstExamples(records, options.top),
|
||||
};
|
||||
}
|
||||
|
||||
function printGroupTable(title, groups, formatter) {
|
||||
if (groups.length === 0) return;
|
||||
console.log(`\n${title}`);
|
||||
for (const group of groups) {
|
||||
console.log(formatter(group));
|
||||
}
|
||||
}
|
||||
|
||||
function printHumanReport(summary) {
|
||||
const { scan, counts, modeStats, multiEditLengthBuckets, argStyles, providerStats, extensionStats, inflation, sameFileClusters, failureKinds, worstExamples, filters } = summary;
|
||||
console.log(`Scanned ${formatInt(scan.sessionFilesIncluded)} session files in ${scan.sessionsDir}`);
|
||||
if (scan.since) {
|
||||
console.log(`Session filter: files created at or after ${scan.since.iso} (${scan.since.source})`);
|
||||
console.log(`Skipped older session files: ${formatInt(scan.sessionFilesSkippedOlderThanSince)} of ${formatInt(scan.sessionFilesScanned)}`);
|
||||
}
|
||||
console.log(`Found ${formatInt(counts.totalEditCalls)} edit tool calls in ${formatInt(counts.assistantMessagesWithEditCalls)} assistant messages`);
|
||||
if (filters.model || filters.extension || filters.failedOnly) {
|
||||
const filterParts = [];
|
||||
if (filters.model) filterParts.push(`model contains \"${filters.model}\"`);
|
||||
if (filters.extension) filterParts.push(`extension = ${filters.extension}`);
|
||||
if (filters.failedOnly) filterParts.push("failed only");
|
||||
console.log(`Filters: ${filterParts.join(", ")}`);
|
||||
}
|
||||
|
||||
console.log("\nSuccess rate");
|
||||
console.log(` success: ${formatInt(counts.success)} ${formatPercent(counts.success, counts.resolvedEditCalls)}`);
|
||||
console.log(` failed: ${formatInt(counts.failed)} ${formatPercent(counts.failed, counts.resolvedEditCalls)}`);
|
||||
console.log(` unresolved: ${formatInt(counts.unresolved)}`);
|
||||
|
||||
console.log("\nMode usage");
|
||||
console.log(` single replacement: ${formatInt(counts.single)} ${formatPercent(counts.single, counts.totalEditCalls)}`);
|
||||
console.log(` multi-edit (edits): ${formatInt(counts.multi)} ${formatPercent(counts.multi, counts.totalEditCalls)}`);
|
||||
|
||||
console.log("\nFailures by edit type");
|
||||
for (const mode of modeStats) {
|
||||
console.log(
|
||||
` ${mode.mode.padEnd(6)} calls=${formatInt(mode.calls).padStart(4)} success=${mode.successRate === null ? "n/a" : formatPercent(mode.success, mode.resolved).padStart(6)} failed=${mode.failureRate === null ? "n/a" : formatPercent(mode.failed, mode.resolved).padStart(6)} unresolved=${formatInt(mode.unresolved)}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\nMulti-edit bucket split");
|
||||
for (const bucket of multiEditLengthBuckets) {
|
||||
console.log(
|
||||
` ${bucket.label.padEnd(20)} ${formatInt(bucket.calls).padStart(4)} calls success=${bucket.successRate === null ? "n/a" : formatPercent(bucket.success, bucket.resolved).padStart(6)} failed=${bucket.failureRate === null ? "n/a" : formatPercent(bucket.failed, bucket.resolved).padStart(6)}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\nArgument style");
|
||||
for (const entry of argStyles) {
|
||||
console.log(` ${entry.style.padEnd(22)} ${formatInt(entry.count).padStart(8)} ${formatPercent(entry.count, counts.totalEditCalls)}`);
|
||||
}
|
||||
|
||||
printGroupTable("By provider/model", providerStats, (group) => {
|
||||
return [
|
||||
` ${group.key}`,
|
||||
` calls: ${formatInt(group.calls)}`,
|
||||
` success: ${group.successRate === null ? "n/a" : formatPercent(group.success, group.resolved)}`,
|
||||
` multi-edit: ${group.multiRate === null ? "n/a" : formatPercent(group.multi, group.calls)}`,
|
||||
` median inflation: ${formatRatio(group.medianInflation)}`,
|
||||
` p95 inflation: ${formatRatio(group.p95Inflation)}`,
|
||||
].join("\n");
|
||||
});
|
||||
|
||||
printGroupTable("By file extension", extensionStats, (group) => {
|
||||
return ` ${group.key.padEnd(10)} calls=${formatInt(group.calls).padStart(6)} success=${group.successRate === null ? "n/a" : formatPercent(group.success, group.resolved).padStart(6)} medianInflation=${formatRatio(group.medianInflation)}`;
|
||||
});
|
||||
|
||||
console.log("\nContext inflation");
|
||||
console.log(` median inflation: ${formatRatio(inflation.median)}`);
|
||||
console.log(` p90 inflation: ${formatRatio(inflation.p90)}`);
|
||||
console.log(` p95 inflation: ${formatRatio(inflation.p95)}`);
|
||||
console.log(` p99 inflation: ${formatRatio(inflation.p99)}`);
|
||||
console.log(` no-core-change: ${formatInt(counts.noCoreChange)}`);
|
||||
|
||||
console.log("\nHuge replacements");
|
||||
for (const entry of inflation.hugeReplacements) {
|
||||
console.log(` >${formatBytes(entry.threshold).padEnd(6)} ${formatInt(entry.count).padStart(8)}`);
|
||||
}
|
||||
|
||||
console.log("\nPathological wrappers");
|
||||
for (const entry of inflation.pathologicalThresholds) {
|
||||
console.log(` inflation >= ${String(entry.threshold).padEnd(3)}x ${formatInt(entry.count).padStart(8)}`);
|
||||
}
|
||||
|
||||
console.log("\nSame-file multi-call behavior");
|
||||
console.log(` assistant msgs with 2+ edit calls to same file: ${formatInt(sameFileClusters.assistantMessagesWithCluster)}`);
|
||||
console.log(` total same-file clusters: ${formatInt(sameFileClusters.clustersCount)}`);
|
||||
console.log(` calls inside those clusters: ${formatInt(sameFileClusters.callsInsideClusters)}`);
|
||||
console.log(` average calls per cluster: ${sameFileClusters.averageCallsPerCluster === null ? "n/a" : sameFileClusters.averageCallsPerCluster.toFixed(2)}`);
|
||||
console.log(` assistant msgs using one multi-edit call: ${formatInt(sameFileClusters.assistantMessagesWithMultiEdit)}`);
|
||||
console.log(` ratio multi-call / multi-edit assistant msgs: ${sameFileClusters.ratioClusterAssistantMessagesToMultiEditAssistantMessages === null ? "n/a" : sameFileClusters.ratioClusterAssistantMessagesToMultiEditAssistantMessages.toFixed(2)}`);
|
||||
|
||||
console.log("\nFailures by kind");
|
||||
if (failureKinds.length === 0) {
|
||||
console.log(" none");
|
||||
} else {
|
||||
for (const failure of failureKinds) {
|
||||
console.log(` ${failure.kind.padEnd(22)} ${formatInt(failure.count).padStart(8)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nFailure rate by inflation bucket");
|
||||
for (const bucket of inflation.failureByBucket) {
|
||||
console.log(` ${bucket.label.padEnd(14)} ${bucket.failureRate === null ? "n/a" : formatPercent(bucket.failed, bucket.resolved).padStart(6)} (${formatInt(bucket.count)} calls)`);
|
||||
}
|
||||
|
||||
console.log(`\nWorst ${formatInt(worstExamples.length)} examples`);
|
||||
for (let i = 0; i < worstExamples.length; i++) {
|
||||
const example = worstExamples[i];
|
||||
console.log(
|
||||
` ${i + 1}. ${example.providerModel} ${example.extension} inflation=${formatRatio(example.inflationRatio)} failed=${example.failed ? example.errorKind : "false"}`
|
||||
);
|
||||
console.log(` path: ${example.path}`);
|
||||
console.log(` totalEditBytes=${formatBytes(example.totalEditBytes)} coreBytes=${formatBytes(example.coreBytes)} mode=${example.mode} edits=${example.editsCount}`);
|
||||
}
|
||||
|
||||
if (scan.malformedLines > 0 || scan.unmatchedToolResults > 0) {
|
||||
console.log("\nParser notes");
|
||||
if (scan.malformedLines > 0) console.log(` malformed lines skipped: ${formatInt(scan.malformedLines)}`);
|
||||
if (scan.unmatchedToolResults > 0) console.log(` unmatched edit tool results: ${formatInt(scan.unmatchedToolResults)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanSessions(sessionsDir, since) {
|
||||
const records = [];
|
||||
const meta = {
|
||||
sessionsDir,
|
||||
sessionFilesScanned: 0,
|
||||
sessionFilesIncluded: 0,
|
||||
sessionFilesSkippedOlderThanSince: 0,
|
||||
sessionFilesWithEditCalls: 0,
|
||||
since,
|
||||
malformedLines: 0,
|
||||
unmatchedToolResults: 0,
|
||||
};
|
||||
|
||||
for await (const sessionFile of walkJsonlFiles(sessionsDir)) {
|
||||
meta.sessionFilesScanned++;
|
||||
const sessionTimestampMs = parseSessionFileTimestamp(sessionFile);
|
||||
if (since && sessionTimestampMs !== null && sessionTimestampMs < since.ms) {
|
||||
meta.sessionFilesSkippedOlderThanSince++;
|
||||
continue;
|
||||
}
|
||||
meta.sessionFilesIncluded++;
|
||||
const pending = new Map();
|
||||
let fileHadEditCall = false;
|
||||
const input = createReadStream(sessionFile, { encoding: "utf8" });
|
||||
const rl = createInterface({ input, crlfDelay: Infinity });
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
meta.malformedLines++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry?.type !== "message" || !entry.message) continue;
|
||||
const message = entry.message;
|
||||
|
||||
if (message.role === "assistant" && Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block?.type !== "toolCall" || block.name !== "edit") continue;
|
||||
fileHadEditCall = true;
|
||||
const analysis = analyzeToolArguments(block.arguments);
|
||||
const record = {
|
||||
sessionFile,
|
||||
assistantEntryId: entry.id,
|
||||
toolCallId: typeof block.id === "string" ? block.id : "",
|
||||
timestamp: entry.timestamp,
|
||||
api: typeof message.api === "string" ? message.api : null,
|
||||
provider: typeof message.provider === "string" ? message.provider : "[unknown]",
|
||||
model: typeof message.model === "string" ? message.model : "[unknown]",
|
||||
providerModel: `${typeof message.provider === "string" ? message.provider : "[unknown]"}/${typeof message.model === "string" ? message.model : "[unknown]"}`,
|
||||
success: null,
|
||||
errorKind: null,
|
||||
errorText: "",
|
||||
resultSummary: "",
|
||||
matchedResult: false,
|
||||
...analysis,
|
||||
};
|
||||
records.push(record);
|
||||
if (record.toolCallId) pending.set(record.toolCallId, record);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.role === "toolResult" && message.toolName === "edit") {
|
||||
const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : "";
|
||||
const record = pending.get(toolCallId);
|
||||
if (!record) {
|
||||
meta.unmatchedToolResults++;
|
||||
continue;
|
||||
}
|
||||
const text = extractTextContent(message.content);
|
||||
record.matchedResult = true;
|
||||
record.success = message.isError === true ? false : true;
|
||||
record.resultSummary = text;
|
||||
record.errorText = message.isError === true ? text : "";
|
||||
record.errorKind = classifyErrorKind(text, message.isError === true, true);
|
||||
pending.delete(toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const record of pending.values()) {
|
||||
record.matchedResult = false;
|
||||
record.success = null;
|
||||
record.errorKind = classifyErrorKind("", false, false);
|
||||
}
|
||||
|
||||
if (fileHadEditCall) meta.sessionFilesWithEditCalls++;
|
||||
}
|
||||
|
||||
return { records, meta };
|
||||
}
|
||||
|
||||
function applyFilters(records, options) {
|
||||
return records.filter((record) => {
|
||||
if (options.modelFilter && !record.providerModel.toLowerCase().includes(options.modelFilter.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (options.extFilter && record.extension !== options.extFilter) {
|
||||
return false;
|
||||
}
|
||||
if (options.failedOnly && record.success !== false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionsDir = path.resolve(options.sessionsDir);
|
||||
await fs.access(sessionsDir);
|
||||
const since = await resolveAutoSinceMs(options);
|
||||
|
||||
const { records, meta } = await scanSessions(sessionsDir, since);
|
||||
const filteredRecords = applyFilters(records, options);
|
||||
const summary = buildSummary(filteredRecords, meta, options);
|
||||
|
||||
if (options.json) {
|
||||
const output = options.includeRecords ? { summary, records: filteredRecords } : { summary };
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
printHumanReport(summary);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user