Detailed changes
@@ -12,14 +12,14 @@ export function resolveModel(
): Model<any> {
const colonIndex = modelString.indexOf(":");
if (colonIndex === -1) {
- throw new ConfigError("Model must be in provider:model format");
+ throw new ConfigError(`Invalid model format: "${modelString}". Expected provider:model (e.g. anthropic:claude-sonnet-4-20250514)`);
}
const provider = modelString.slice(0, colonIndex);
const modelName = modelString.slice(colonIndex + 1);
if (!provider || !modelName) {
- throw new ConfigError("Model must be in provider:model format");
+ throw new ConfigError(`Invalid model format: "${modelString}". Expected provider:model (e.g. anthropic:claude-sonnet-4-20250514)`);
}
// Handle custom models
@@ -34,7 +34,7 @@ export function resolveModel(
function resolveCustomModel(modelName: string, config: RumiloConfig): Model<any> {
if (!config.custom_models) {
throw new ConfigError(
- `No custom models configured. Use 'custom:' prefix only with custom model definitions in config.`,
+ `No custom models defined in config. Add a [custom_models.${modelName}] section to use custom:${modelName}`,
);
}
@@ -80,7 +80,7 @@ export async function runAgent(query: string, options: AgentRunOptions): Promise
.trim();
if (text === undefined || text === "") {
- throw new AgentError("Agent returned no text response");
+ throw new AgentError("Agent completed without producing a text response");
}
const requestCount = agent.state.messages.filter((msg) => msg.role === "assistant").length;
@@ -17,12 +17,12 @@ const FindSchema = Type.Object({
export const createFindTool = (workspacePath: string): AgentTool => {
const fdResult = spawnSync("which", ["fd"], { encoding: "utf-8" });
const fdPath = fdResult.stdout?.trim();
- if (!fdPath) throw new ToolInputError("fd is not available");
+ if (!fdPath) throw new ToolInputError("find requires fd to be installed");
return {
name: "find",
label: "Find Files",
- description: `Search for files by glob pattern using fd. Returns up to ${DEFAULT_LIMIT} results and ${DEFAULT_MAX_BYTES / 1024} KB of output. Respects .gitignore.`,
+ description: `Search for files matching a glob pattern. Returns up to ${DEFAULT_LIMIT} results and ${DEFAULT_MAX_BYTES / 1024}KB of output. Respects .gitignore.`,
parameters: FindSchema as any,
execute: async (_toolCallId: string, params: any) => {
const searchDir: string = params.path || ".";
@@ -15,7 +15,7 @@ const BlameSchema = Type.Object({
export const createGitBlameTool = (workspacePath: string): AgentTool => ({
name: "git_blame",
label: "Git Blame",
- description: "Blame a file to see commit attribution.",
+ description: "Show line-by-line commit attribution for a file.",
parameters: BlameSchema as any,
execute: async (_toolCallId: string, params: any) => {
if (!String(params.path ?? "").trim()) {
@@ -17,7 +17,7 @@ const DiffSchema = Type.Object({
export const createGitDiffTool = (workspacePath: string): AgentTool => ({
name: "git_diff",
label: "Git Diff",
- description: "Show diff between refs or working tree.",
+ description: "Show diff between refs, or between a ref and the working tree. Omit both refs for unstaged changes.",
parameters: DiffSchema as any,
execute: async (_toolCallId: string, params: any) => {
const git = simpleGit(workspacePath);
@@ -21,7 +21,7 @@ const LogSchema = Type.Object({
export const createGitLogTool = (workspacePath: string): AgentTool => ({
name: "git_log",
label: "Git Log",
- description: "View commit history. Supports filtering by path, author, date range, and count.",
+ description: "View commit history with optional path, author, and date filters.",
parameters: LogSchema as any,
execute: async (_toolCallId: string, params: any) => {
const git = simpleGit(workspacePath);
@@ -18,7 +18,7 @@ const RefsSchema = Type.Object({
export const createGitRefsTool = (workspacePath: string): AgentTool => ({
name: "git_refs",
label: "Git Refs",
- description: "List branches or tags.",
+ description: "List branches, tags, or remotes.",
parameters: RefsSchema as any,
execute: async (_toolCallId: string, params: any) => {
const git = simpleGit(workspacePath);
@@ -15,7 +15,7 @@ const ShowSchema = Type.Object({
export const createGitShowTool = (workspacePath: string): AgentTool => ({
name: "git_show",
label: "Git Show",
- description: "Show details for a commit or object.",
+ description: "Show commit message and diff for a given ref.",
parameters: ShowSchema as any,
execute: async (_toolCallId: string, params: any) => {
if (!String(params.ref ?? "").trim()) {
@@ -31,14 +31,14 @@ const GrepSchema = Type.Object({
export const createGrepTool = (workspacePath: string): AgentTool => {
const rgResult = spawnSync("which", ["rg"], { encoding: "utf-8" });
if (rgResult.status !== 0) {
- throw new ToolInputError("ripgrep (rg) is not available");
+ throw new ToolInputError("grep requires ripgrep (rg) to be installed");
}
const rgPath = rgResult.stdout.trim();
return {
name: "grep",
label: "Grep",
- description: `Search file contents for a pattern using ripgrep. Returns up to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Respects .gitignore.`,
+ description: `Search file contents for a pattern. Returns up to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB, whichever is reached first. Lines over ${GREP_MAX_LINE_LENGTH} chars are truncated. Respects .gitignore.`,
parameters: GrepSchema as any,
execute: async (_toolCallId: string, params: any) => {
const searchDir: string | undefined = params.path;
@@ -15,7 +15,7 @@ const LsSchema = Type.Object({
export const createLsTool = (workspacePath: string): AgentTool => ({
name: "ls",
label: "List Directory",
- description: `List directory contents. Returns up to ${DEFAULT_LIMIT} entries and ${DEFAULT_MAX_BYTES / 1024} KB of output.`,
+ description: `List directory contents. Directories are suffixed with /. Returns up to ${DEFAULT_LIMIT} entries and ${DEFAULT_MAX_BYTES / 1024}KB of output.`,
parameters: LsSchema as any,
execute: async (_toolCallId: string, params: any) => {
const resolved = resolveToCwd(params.path || ".", workspacePath);
@@ -8,7 +8,7 @@ import { ToolInputError } from "../../util/errors.js";
const MAX_READ_BYTES = 5 * 1024 * 1024;
const ReadSchema = Type.Object({
- path: Type.String({ description: "Path relative to workspace root" }),
+ path: Type.String({ description: "File path relative to workspace root" }),
offset: Type.Optional(Type.Number({ description: "1-based starting line (default: 1)" })),
limit: Type.Optional(Type.Number({ description: "Maximum lines to return" })),
});
@@ -16,7 +16,7 @@ const ReadSchema = Type.Object({
export const createReadTool = (workspacePath: string): AgentTool => ({
name: "read",
label: "Read File",
- description: `Read a file's contents. Output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`,
+ description: `Read a file's contents. Returns up to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB, whichever is reached first. Use offset and limit to paginate large files.`,
parameters: ReadSchema as any,
execute: async (_toolCallId: string, params: any) => {
const absolutePath = resolveReadPath(params.path, workspacePath);
@@ -24,7 +24,7 @@ export const createReadTool = (workspacePath: string): AgentTool => ({
const fileStats = await stat(absolutePath);
if (fileStats.size > MAX_READ_BYTES) {
- throw new ToolInputError(`File exceeds 5MB limit: ${params.path}`);
+ throw new ToolInputError(`File too large (>5MB): ${params.path}`);
}
const raw = await readFile(absolutePath, "utf8");
@@ -10,18 +10,18 @@ export interface WebFetchResult {
const FetchSchema = Type.Object({
url: Type.String({ description: "URL to fetch" }),
- nocache: Type.Optional(Type.Boolean({ description: "Force fresh fetch" })),
+ nocache: Type.Optional(Type.Boolean({ description: "Bypass cache and fetch fresh content" })),
});
export function createWebFetchTool(apiKey: string): AgentTool {
return {
name: "web_fetch",
label: "Web Fetch",
- description: "Fetch a URL and return markdown using Tabstack.",
+ description: "Fetch a URL and return its content as markdown.",
parameters: FetchSchema as any,
execute: async (_toolCallId: string, params: any) => {
if (!apiKey) {
- throw new ToolInputError("Missing Tabstack API key");
+ throw new ToolInputError("Web fetch is not configured");
}
const client = new Tabstack({ apiKey });
@@ -10,11 +10,11 @@ const SearchSchema = Type.Object({
export const createWebSearchTool = (sessionToken: string): AgentTool => ({
name: "web_search",
label: "Web Search",
- description: "Search the web using Kagi (session token required).",
+ description: "Search the web. Returns structured results with titles, URLs, and snippets.",
parameters: SearchSchema as any,
execute: async (_toolCallId: string, params: any) => {
if (!sessionToken) {
- throw new ToolInputError("Missing Kagi session token");
+ throw new ToolInputError("Web search is not configured");
}
try {
@@ -45,10 +45,10 @@ export async function runWebCommand(options: WebCommandOptions): Promise<void> {
process.env["TABSTACK_API_KEY"];
if (!kagiSession) {
- throw new ConfigError("Missing Kagi session token (set KAGI_SESSION_TOKEN or config)");
+ throw new ConfigError("Web search requires KAGI_SESSION_TOKEN (set via environment or config)");
}
if (!tabstackKey) {
- throw new ConfigError("Missing Tabstack API key (set TABSTACK_API_KEY or config)");
+ throw new ConfigError("Web fetch requires TABSTACK_API_KEY (set via environment or config)");
}
let systemPrompt = WEB_SYSTEM_PROMPT;
@@ -28,8 +28,27 @@ async function main() {
}
if (!actualCommand || actualCommand === "help" || actualCommand === "--help" || actualCommand === "-h" || options["help"]) {
- console.log("rumilo web <query> [-u URL] [--model <provider:model>] [--verbose] [--no-cleanup]");
- console.log("rumilo repo -u <uri> <query> [--ref <ref>] [--full] [--model <provider:model>] [--verbose] [--no-cleanup]");
+ console.log(`rumilo v${VERSION} β dispatch AI research subagents
+
+Commands:
+ web Search the web and synthesize an answer
+ repo Clone and explore a git repository
+
+Usage:
+ rumilo web <query> [-u <url>] [options]
+ rumilo repo -u <uri> <query> [options]
+
+Options:
+ -u <url> Seed URL to pre-fetch (web) or repository to clone (repo)
+ --model <provider:model> Override the default model
+ --ref <ref> Checkout a specific ref after cloning (repo only)
+ --full Full clone instead of shallow (repo only)
+ --verbose Show tool calls and results on stderr
+ --no-cleanup Preserve the workspace directory after exit
+ -v, --version Print version and exit
+
+Configuration:
+ $XDG_CONFIG_HOME/rumilo/config.toml`);
process.exit(0);
}
@@ -37,7 +56,7 @@ async function main() {
if (command === "web") {
const query = positional.join(" ");
if (!query) {
- throw new RumiloError("Missing query", "CLI_ERROR");
+ throw new RumiloError("Missing query. Usage: rumilo web <query>", "CLI_ERROR");
}
await runWebCommand({
@@ -54,10 +73,10 @@ async function main() {
const query = positional.join(" ");
const uri = options["uri"] ? String(options["uri"]) : undefined;
if (!uri) {
- throw new RumiloError("Missing repo URI", "CLI_ERROR");
+ throw new RumiloError("Missing repository URI. Usage: rumilo repo -u <uri> <query>", "CLI_ERROR");
}
if (!query) {
- throw new RumiloError("Missing query", "CLI_ERROR");
+ throw new RumiloError("Missing query. Usage: rumilo repo -u <uri> <query>", "CLI_ERROR");
}
await runRepoCommand({
@@ -15,6 +15,7 @@ beforeAll(async () => {
await git.init();
await git.addConfig("user.name", "Test");
await git.addConfig("user.email", "test@test.com");
+ await git.addConfig("commit.gpgsign", "false");
writeFileSync(join(workDir, "file.txt"), "hello");
await git.add("file.txt");
await git.commit("initial commit");
@@ -22,6 +22,7 @@ beforeAll(async () => {
await git.init();
await git.addConfig("user.name", "Test");
await git.addConfig("user.email", "test@test.com");
+ await git.addConfig("commit.gpgsign", "false");
// Create a large file for truncation tests
const largeLine = "x".repeat(100);
@@ -78,7 +78,7 @@ describe("repo command β workspace cleanup on early failure", () => {
const workClone = mkdtempSync(join(tmpdir(), "rumilo-test-work-"));
execSync(`git clone ${localRepo} work`, { cwd: workClone, stdio: "ignore" });
const workDir = join(workClone, "work");
- execSync("git config user.email test@test.com && git config user.name Test", { cwd: workDir, stdio: "ignore" });
+ execSync("git config user.email test@test.com && git config user.name Test && git config commit.gpgsign false", { cwd: workDir, stdio: "ignore" });
execSync("echo hello > README.md && git add . && git commit -m init", { cwd: workDir, stdio: "ignore" });
execSync("git push", { cwd: workDir, stdio: "ignore" });
// Clean up work clone