src / agents / tools / inspect.ts
src / agents / tools / inspect.ts
import { tool, type Tool } from "@lmstudio/sdk";
import { z } from "zod";
import { errorResult, okResult } from "../../core/result";
import { searchWorkspace } from "../../workspace/search";
import { numberedRange, readTextSnapshot } from "../../workspace/text";
import { walkWorkspace } from "../../workspace/walk";
import type { AgentToolContext } from "./context";
/** `inspect_tree` — a bounded directory walk. */
export function createInspectTreeTool(ctx: AgentToolContext): Tool {
const { beforeTool } = ctx;
return tool({
name: "inspect_tree",
description: "Inspect a bounded workspace tree before deciding which files to read.",
parameters: {
path: z.string().optional(),
max_depth: z.number().int().min(0).max(8).optional(),
max_entries: z.number().int().min(1).max(500).optional(),
},
implementation: async (input: {
path?: string;
max_depth?: number;
max_entries?: number;
}) => {
await beforeTool("inspect_tree");
try {
const result = await walkWorkspace(ctx.boundary, input.path ?? ".", {
maxDepth: input.max_depth ?? 4,
maxEntries: input.max_entries ?? 200,
});
return okResult({
operation: "workspace.tree",
summary: `Listed ${result.entries.length} entries${result.truncated ? " (truncated)" : ""}.`,
data: result,
omit: ["data.entries"],
});
} catch (error) {
return errorResult("workspace.tree", error);
}
},
});
}
/**
* How many lines `read_file` returns when `end_line` is omitted. Anchored to
* `start_line` (`start .. start + N - 1`), not to the top of the file:
* `numberedRange` clamps `end` up to `start`, so an absolute default such as
* `min(lineCount, 400)` returned a single line for any read starting past line
* 400. `numberedRange` still clamps the window to the end of the file.
*/
const READ_WINDOW_LINES = 400;
/** `read_file` — a line-numbered, hash-stamped read. */
export function createReadFileTool(ctx: AgentToolContext): Tool {
const { state, beforeTool } = ctx;
return tool({
name: "read_file",
description:
"Read a text file with line numbers, a bounded range, and a SHA-256 precondition hash.",
parameters: {
path: z.string(),
start_line: z.number().int().min(1).optional(),
end_line: z.number().int().min(1).optional(),
},
implementation: async (input: {
path: string;
start_line?: number;
end_line?: number;
}) => {
await beforeTool("read_file");
try {
const absolute = await ctx.boundary.resolveRead(input.path);
const snapshot = await readTextSnapshot(absolute, ctx.options.maxReadBytes);
const startLine = input.start_line ?? 1;
const range = numberedRange(
snapshot.content,
startLine,
input.end_line ?? startLine + READ_WINDOW_LINES - 1,
);
const path = ctx.boundary.relativePath(absolute);
state.filesRead = [
...state.filesRead.filter((item) => item.path !== path),
{ path, sha256: snapshot.sha256 },
].slice(-100);
await ctx.store.save(state);
return okResult({
operation: "workspace.read",
summary: `Read ${path} lines ${range.startLine}-${range.endLine} of ${range.totalLines}.`,
data: {
path,
sha256: snapshot.sha256,
bytes: snapshot.bytes,
lines: range.text,
totalLines: range.totalLines,
},
facts: [`${path} sha256 ${snapshot.sha256}`],
omit: ["data.lines"],
});
} catch (error) {
return errorResult("workspace.read", error);
}
},
});
}
/** `search_workspace` — literal or regex text search. */
export function createSearchWorkspaceTool(ctx: AgentToolContext): Tool {
const { beforeTool } = ctx;
return tool({
name: "search_workspace",
description: "Search text files using a literal query or a bounded regular expression.",
parameters: {
query: z.string(),
path: z.string().optional(),
regex: z.boolean().optional(),
case_sensitive: z.boolean().optional(),
max_results: z.number().int().min(1).max(100).optional(),
},
implementation: async (input: {
query: string;
path?: string;
regex?: boolean;
case_sensitive?: boolean;
max_results?: number;
}) => {
await beforeTool("search_workspace");
try {
const result = await searchWorkspace(ctx.boundary, {
query: input.query,
path: input.path,
regex: input.regex,
caseSensitive: input.case_sensitive,
maxResults: input.max_results ?? 40,
maxFiles: 500,
maxFileBytes: 1_500_000,
contextLines: 1,
});
return okResult({
operation: "workspace.search",
summary: `Found ${result.matches.length} match(es) across ${result.filesScanned} files.`,
data: result,
omit: ["data.matches"],
});
} catch (error) {
return errorResult("workspace.search", error);
}
},
});
}
import { tool, type Tool } from "@lmstudio/sdk";
import { z } from "zod";
import { errorResult, okResult } from "../../core/result";
import { searchWorkspace } from "../../workspace/search";
import { numberedRange, readTextSnapshot } from "../../workspace/text";
import { walkWorkspace } from "../../workspace/walk";
import type { AgentToolContext } from "./context";
/** `inspect_tree` — a bounded directory walk. */
export function createInspectTreeTool(ctx: AgentToolContext): Tool {
const { beforeTool } = ctx;
return tool({
name: "inspect_tree",
description: "Inspect a bounded workspace tree before deciding which files to read.",
parameters: {
path: z.string().optional(),
max_depth: z.number().int().min(0).max(8).optional(),
max_entries: z.number().int().min(1).max(500).optional(),
},
implementation: async (input: {
path?: string;
max_depth?: number;
max_entries?: number;
}) => {
await beforeTool("inspect_tree");
try {
const result = await walkWorkspace(ctx.boundary, input.path ?? ".", {
maxDepth: input.max_depth ?? 4,
maxEntries: input.max_entries ?? 200,
});
return okResult({
operation: "workspace.tree",
summary: `Listed ${result.entries.length} entries${result.truncated ? " (truncated)" : ""}.`,
data: result,
omit: ["data.entries"],
});
} catch (error) {
return errorResult("workspace.tree", error);
}
},
});
}
/**
* How many lines `read_file` returns when `end_line` is omitted. Anchored to
* `start_line` (`start .. start + N - 1`), not to the top of the file:
* `numberedRange` clamps `end` up to `start`, so an absolute default such as
* `min(lineCount, 400)` returned a single line for any read starting past line
* 400. `numberedRange` still clamps the window to the end of the file.
*/
const READ_WINDOW_LINES = 400;
/** `read_file` — a line-numbered, hash-stamped read. */
export function createReadFileTool(ctx: AgentToolContext): Tool {
const { state, beforeTool } = ctx;
return tool({
name: "read_file",
description:
"Read a text file with line numbers, a bounded range, and a SHA-256 precondition hash.",
parameters: {
path: z.string(),
start_line: z.number().int().min(1).optional(),
end_line: z.number().int().min(1).optional(),
},
implementation: async (input: {
path: string;
start_line?: number;
end_line?: number;
}) => {
await beforeTool("read_file");
try {
const absolute = await ctx.boundary.resolveRead(input.path);
const snapshot = await readTextSnapshot(absolute, ctx.options.maxReadBytes);
const startLine = input.start_line ?? 1;
const range = numberedRange(
snapshot.content,
startLine,
input.end_line ?? startLine + READ_WINDOW_LINES - 1,
);
const path = ctx.boundary.relativePath(absolute);
state.filesRead = [
...state.filesRead.filter((item) => item.path !== path),
{ path, sha256: snapshot.sha256 },
].slice(-100);
await ctx.store.save(state);
return okResult({
operation: "workspace.read",
summary: `Read ${path} lines ${range.startLine}-${range.endLine} of ${range.totalLines}.`,
data: {
path,
sha256: snapshot.sha256,
bytes: snapshot.bytes,
lines: range.text,
totalLines: range.totalLines,
},
facts: [`${path} sha256 ${snapshot.sha256}`],
omit: ["data.lines"],
});
} catch (error) {
return errorResult("workspace.read", error);
}
},
});
}
/** `search_workspace` — literal or regex text search. */
export function createSearchWorkspaceTool(ctx: AgentToolContext): Tool {
const { beforeTool } = ctx;
return tool({
name: "search_workspace",
description: "Search text files using a literal query or a bounded regular expression.",
parameters: {
query: z.string(),
path: z.string().optional(),
regex: z.boolean().optional(),
case_sensitive: z.boolean().optional(),
max_results: z.number().int().min(1).max(100).optional(),
},
implementation: async (input: {
query: string;
path?: string;
regex?: boolean;
case_sensitive?: boolean;
max_results?: number;
}) => {
await beforeTool("search_workspace");
try {
const result = await searchWorkspace(ctx.boundary, {
query: input.query,
path: input.path,
regex: input.regex,
caseSensitive: input.case_sensitive,
maxResults: input.max_results ?? 40,
maxFiles: 500,
maxFileBytes: 1_500_000,
contextLines: 1,
});
return okResult({
operation: "workspace.search",
summary: `Found ${result.matches.length} match(es) across ${result.filesScanned} files.`,
data: result,
omit: ["data.matches"],
});
} catch (error) {
return errorResult("workspace.search", error);
}
},
});
}