feat: misc tool alignment (#137)

This commit is contained in:
Charles Packer
2025-11-30 15:38:04 -08:00
committed by GitHub
parent b0291597f3
commit 6089ce1cdd
40 changed files with 1524 additions and 206 deletions

View File

@@ -0,0 +1,80 @@
import { validateRequiredParams } from "./validation.js";
interface QuestionOption {
label: string;
description: string;
}
interface Question {
question: string;
header: string;
options: QuestionOption[];
multiSelect: boolean;
}
interface AskUserQuestionArgs {
questions: Question[];
answers?: Record<string, string>;
}
interface AskUserQuestionResult {
message: string;
}
export async function ask_user_question(
args: AskUserQuestionArgs,
): Promise<AskUserQuestionResult> {
validateRequiredParams(args, ["questions"], "AskUserQuestion");
if (!Array.isArray(args.questions) || args.questions.length === 0) {
throw new Error("questions must be a non-empty array");
}
if (args.questions.length > 4) {
throw new Error("Maximum of 4 questions allowed");
}
for (const q of args.questions) {
if (!q.question || typeof q.question !== "string") {
throw new Error("Each question must have a question string");
}
if (!q.header || typeof q.header !== "string") {
throw new Error("Each question must have a header string");
}
if (
!Array.isArray(q.options) ||
q.options.length < 2 ||
q.options.length > 4
) {
throw new Error("Each question must have 2-4 options");
}
if (typeof q.multiSelect !== "boolean") {
throw new Error("Each question must have a multiSelect boolean");
}
for (const opt of q.options) {
if (!opt.label || typeof opt.label !== "string") {
throw new Error("Each option must have a label string");
}
if (!opt.description || typeof opt.description !== "string") {
throw new Error("Each option must have a description string");
}
}
}
// If answers are provided (filled in by UI layer), format the response
if (args.answers && Object.keys(args.answers).length > 0) {
const answerParts = args.questions.map((q) => {
const answer = args.answers?.[q.question] || "";
return `"${q.question}"="${answer}"`;
});
return {
message: `User has answered your questions: ${answerParts.join(", ")}. You can now continue with the user's answers in mind.`,
};
}
// Otherwise, return a placeholder - the UI layer should intercept this tool call
// and show the question UI before returning the actual response
return {
message: "Waiting for user response...",
};
}

View File

@@ -3,7 +3,7 @@ import { LIMITS, truncateByChars } from "./truncation.js";
import { validateRequiredParams } from "./validation.js";
interface BashOutputArgs {
bash_id: string;
shell_id: string;
filter?: string;
}
interface BashOutputResult {
@@ -13,11 +13,11 @@ interface BashOutputResult {
export async function bash_output(
args: BashOutputArgs,
): Promise<BashOutputResult> {
validateRequiredParams(args, ["bash_id"], "BashOutput");
const { bash_id, filter } = args;
const proc = backgroundProcesses.get(bash_id);
validateRequiredParams(args, ["shell_id"], "BashOutput");
const { shell_id, filter } = args;
const proc = backgroundProcesses.get(shell_id);
if (!proc)
return { message: `No background process found with ID: ${bash_id}` };
return { message: `No background process found with ID: ${shell_id}` };
const stdout = proc.stdout.join("\n");
const stderr = proc.stderr.join("\n");
let text = stdout;

View File

@@ -0,0 +1,32 @@
interface EnterPlanModeArgs {
[key: string]: never;
}
interface EnterPlanModeResult {
message: string;
}
export async function enter_plan_mode(
_args: EnterPlanModeArgs,
): Promise<EnterPlanModeResult> {
// This is handled by the UI layer which will:
// 1. Show approval dialog
// 2. On approve: toggle plan mode on, generate plan file path, inject system reminder
// 3. On reject: send rejection, agent proceeds without plan mode
//
// The message below is returned on successful entry into plan mode.
// The UI harness will also inject a <system-reminder> with the plan file path.
return {
message: `Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach.
In plan mode, you should:
1. Thoroughly explore the codebase to understand existing patterns
2. Identify similar features and architectural approaches
3. Consider multiple approaches and their trade-offs
4. Use AskUserQuestion if you need to clarify the approach
5. Design a concrete implementation strategy
6. When ready, use ExitPlanMode to present your plan for approval
Remember: DO NOT write or edit any files yet. This is a read-only exploration and planning phase.`,
};
}

View File

@@ -1,22 +1,11 @@
/**
* ExitPlanMode tool implementation
* Exits plan mode by presenting the plan to the user for approval
* Exits plan mode - the plan is read from the plan file by the UI
*/
import { validateRequiredParams } from "./validation.js";
interface ExitPlanModeArgs {
plan: string;
}
export async function exit_plan_mode(
args: ExitPlanModeArgs,
): Promise<{ message: string }> {
validateRequiredParams(args, ["plan"], "ExitPlanMode");
const { plan: _plan } = args;
export async function exit_plan_mode(): Promise<{ message: string }> {
// Return confirmation message that plan was approved
// Note: The plan itself should be displayed by the UI/system before this return is shown
// Note: The plan is read from the plan file by the UI before this return is shown
return {
message:
"User has approved your plan. You can now start coding.\nStart with updating your todo list if applicable",

View File

@@ -21,6 +21,18 @@ function getRipgrepPath(): string {
const rgPath = getRipgrepPath();
function applyOffsetAndLimit<T>(
items: T[],
offset: number,
limit: number,
): T[] {
const sliced = items.slice(offset);
if (limit > 0) {
return sliced.slice(0, limit);
}
return sliced; // 0 = unlimited
}
export interface GrepArgs {
pattern: string;
path?: string;
@@ -32,6 +44,8 @@ export interface GrepArgs {
"-n"?: boolean;
"-i"?: boolean;
type?: string;
head_limit?: number;
offset?: number;
multiline?: boolean;
}
@@ -51,9 +65,11 @@ export async function grep(args: GrepArgs): Promise<GrepResult> {
"-B": before,
"-A": after,
"-C": context,
"-n": lineNumbers,
"-n": lineNumbers = true,
"-i": ignoreCase,
type: fileType,
head_limit = 100,
offset = 0,
multiline,
} = args;
@@ -88,12 +104,14 @@ export async function grep(args: GrepArgs): Promise<GrepResult> {
cwd: userCwd,
});
if (output_mode === "files_with_matches") {
const files = stdout.trim().split("\n").filter(Boolean);
const allFiles = stdout.trim().split("\n").filter(Boolean);
const files = applyOffsetAndLimit(allFiles, offset, head_limit);
const fileCount = files.length;
if (fileCount === 0) return { output: "No files found", files: 0 };
const totalCount = allFiles.length;
if (totalCount === 0) return { output: "No files found", files: 0 };
const fileList = files.join("\n");
const fullOutput = `Found ${fileCount} file${fileCount !== 1 ? "s" : ""}\n${fileList}`;
const fullOutput = `Found ${totalCount} file${totalCount !== 1 ? "s" : ""}${fileCount < totalCount ? ` (showing ${fileCount})` : ""}\n${fileList}`;
// Apply character limit to prevent large file lists
const { content: truncatedOutput } = truncateByChars(
@@ -104,13 +122,14 @@ export async function grep(args: GrepArgs): Promise<GrepResult> {
return {
output: truncatedOutput,
files: fileCount,
files: totalCount,
};
} else if (output_mode === "count") {
const lines = stdout.trim().split("\n").filter(Boolean);
const allLines = stdout.trim().split("\n").filter(Boolean);
const lines = applyOffsetAndLimit(allLines, offset, head_limit);
let totalMatches = 0;
let filesWithMatches = 0;
for (const line of lines) {
for (const line of allLines) {
const parts = line.split(":");
if (parts.length >= 2) {
const lastPart = parts[parts.length - 1];
@@ -138,16 +157,20 @@ export async function grep(args: GrepArgs): Promise<GrepResult> {
if (!stdout || stdout.trim() === "")
return { output: "No matches found", matches: 0 };
const allLines = stdout.split("\n");
const lines = applyOffsetAndLimit(allLines, offset, head_limit);
const content = lines.join("\n");
// Apply character limit to content output
const { content: truncatedOutput } = truncateByChars(
stdout,
content,
LIMITS.GREP_OUTPUT_CHARS,
"Grep",
);
return {
output: truncatedOutput,
matches: stdout.split("\n").filter(Boolean).length,
matches: allLines.filter(Boolean).length,
};
}
} catch (error) {

View File

@@ -133,6 +133,11 @@ export async function read(args: ReadArgs): Promise<ReadResult> {
if (await isBinaryFile(file_path))
throw new Error(`Cannot read binary file: ${file_path}`);
const content = await fs.readFile(file_path, "utf-8");
if (content.trim() === "") {
return {
content: `<system-reminder>\nThe file ${file_path} exists but has empty contents.\n</system-reminder>`,
};
}
const formattedContent = formatWithLineNumbers(content, offset, limit);
return { content: formattedContent };
} catch (error) {

View File

@@ -3,8 +3,7 @@ import { validateRequiredParams } from "./validation.js";
interface TodoItem {
content: string;
status: "pending" | "in_progress" | "completed";
id: string;
priority?: "high" | "medium" | "low";
activeForm: string;
}
interface TodoWriteArgs {
todos: TodoItem[];
@@ -29,10 +28,8 @@ export async function todo_write(
throw new Error(
"Each todo must have a valid status (pending, in_progress, or completed)",
);
if (!todo.id || typeof todo.id !== "string")
throw new Error("Each todo must have an id string");
if (todo.priority && !["high", "medium", "low"].includes(todo.priority))
throw new Error("If provided, priority must be high, medium, or low");
if (!todo.activeForm || typeof todo.activeForm !== "string")
throw new Error("Each todo must have an activeForm string");
}
return {
message: